It's all about the user experience...
[
Update: There is a small bug with this posting. I've made a follow up post here to explain the problem and the solution as well]
We all know that the quickest way to kill a web site is to have all of your users disappear. After all, a web site without users is pretty pointless. We are constantly striving to bring fresh new content and the latest and greatest "WOW" factor to our sites in order to not only garner new users, but to keep the users we already have coming back.
Users *HATE* slow sites. This is common knowledge, and when Steve put together his 5 truths about web users, he made this #2 on his list:
2. Users have more than a LAN between their browser and your servers.
I am in the middle of building the latest and greatest. The main page has 15 uncompressed javascript files, 10 background gifs, a couple of extra css files, and sends almost half a meg of data to render the page. It takes about a second for all of this to show up in my browser.
Of course, my browser is sitting on the server, and I've got endless RAM and multiple processors.
When I put this on a test server and hit it from home through DSL, the cloud, and our shared pipe at work - a second becomes over 10 seconds. Long enough that I check ESPN for any new football news.
Having a slow app is like building a car with doors that don't open. People won't buy a car if they have to crawl through the window to get in.
Put your app on an external server and hit it with consumer-grade net access.
Enter View State, Killer of User Experience
View State is a mechanism employed by ASP.Net web pages to persist the state of the page itself, individual controls, objects and data that are housed on that particular ASP.Net web page. View State is a double edged sword in that using it properly allows the developer to build full and robust web applications that seem to overcome the stateless nature of the web. Scott Mitchell has written an excellent article on view state some years ago that is published on MSDN. I won't go into the detail on view state that he does (since he's already done an excellent job), but I will mention a few things:
- To much of a good thing... By default, every control that you add to an ASP.Net web page has view state enabled. Every button, label, grid, drop down.. everything. That means every control on your page makes the size of the view state grow.
- View State is persisted in the HTML Markup. That's right boys and girls. Every control on your page that makes use of view state, making that view state bigger.. makes your overall page size bigger as well.
- View State travels both ways. Since view state is used by ASP.Net, and ASP.Net only exists on the server, then that view state that was sent down to the browser, has to be sent BACK to the server for it to be used. Yes, that includes partial page updates with AJAX as well.
- View State is ONLY used on the server. I know I said this in #3, but it bears repeating here... the only place that view state is EVER used, is on the server, by the ASP.Net runtime...
Even the most responsible developers can be plagued by a large view state...
As the complexity of our web applications grow, more and more controls get added to the screen, more and more data needs to be persisted... view state grows. There's no way to avoid it. Even if we are diligent in our efforts in only making sure that those things that need view state are the only ones with it turned on, view state can grow to several kilobytes in size. It's not uncommon to have view state sizes that run into the 50k to 100k range on custom intranet/extranet applications.
Couple this with the fact that view has to be sent down to the browser, and back up to the server on every post back and asynchronous post back done via AJAX, we can see how just the sheer size of the web page causes our overall application performance to tank. The thing is, ever growing view state is not a new problem. Over the years, many people (most much smarter than I am) have come up with methods to combat the size. Scott Hanselman put together a blog post on how to compress the view state before it's sent to the browser as had several other authors. Another solution often sought after is moving the view state from the top of the page to the bottom of the page. Moving the view state enables the browser to start rendering the HTML content right away instead of waiting for this massive hidden field content to stream down. However, that still doesn't negate the fact that the view state has to travel back up to the server.
No, to solve this view state problem once and for all, I'd like to redirect your attention to rule #4...
View State is *ONLY* used on the server!!!
It seems logical to me, that if view state is only used on the server, then we should keep it on the server and not make it travel down the wire to the client browser, and back up to the server... every... single... time.
History
I stumbled upon a Code Project article entitled Keep ASP.Net ViewState out of ASPX Page for Performance Improvement which took it's inspiration from an article posted by Peter Bromberg. The basic premise of the article is to override the System.Web.UI.Page SavePageStateToPersistenceMedium and LoadPageStateFromPersistenceMedium methods. The code samples show how to take the view state and save them in either the Page.Cache or in Session.. or some combination there of. In developing our solution, I opted to persist the view state into cache. I was extremely excited to see that my average page size went from 70k in size to just over 20k. Feeling good about the initial tests, I rolled the branch into our code based and published the code to our training server to put a slightly heaver load on the code for testing. After about 3 hours of use during a training class, the server came crashing to it's knees.
Sever resources are not infinite.
What had happened, was now, instead of the 30k to 60k view state being sent back and forth to the client, it was living happily in Page.Cache. Of course, Page.Cache is an in-memory caching device. So for 50 users, over the course of 3 hours.. the ASP.Net cache grew to the point that it choked out the rest of the memory available to the ASP.Net worker process.
Not wanting to give up on the concept (I still believed that it was the best way to handle our large view state problem and keep the user experience optimal), I went "back to the drawing board" and came up with another solution to persist the view state on the server, while still being mindful of the finite server resources.
Rethinking the design
Requirements:
- The view state needs to be persisted on the server
- The view state persistence mechanism needs to be identified by a specific user session
- The persisted view state artifact must not be allowed to remain forever
- The persisted view state should be able to be enabled and disabled on a page by page bases
- Different persistence mechanism's should be able to be used.
- Page development and structure should not be modified.
Control Adapters to the Rescue
Since the ASP.Net Page object, at it's core is a control (granted.. it is *THE* control.. but a control non-the-less) we're able to modify it's behavior with a simple control adapter. Typically, when speaking about control adapters, the development community at large thinks about CSS Control Adapters.
MSDN defines control adapters thusly:
Control adapters are components that override certain Control class methods and events in its execution lifecycle to allow browser or markup-specific handling. The .NET Framework maps a single derived control adapter to a Control object for each client request.
I had discovered this rather excellent (although brief) article by Robert Boedigheimer on using Server Side View state in ASP.Net using the SessionPageStatePersister. I knew then that this was the road I wanted to go down... although using the SessionPageStatePersister would run into the same problems with finite memory resources.. so that as a brush stroke solution wouldn't do...
My solution actually contains two parts. The first part is the control adapter PageStateAdapter, and the second is the custom persistence mechanism CachePageStatePersister.
PageStateAdapter
To solve my requirements #4 and #5, I decided to go with a simple attribute scheme. Where basically, the developer can decide to use a different view state persistence scheme simply by putting an attribute at the top of the pages class definition. The attribute class and supporting enum are both defined in the PageStateAdapter class
1: public enum StateStorageTypes { Default, Cache, Session, InPage }
2:
3: [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
4: public class PageViewStateStorageAttribute : Attribute
5: {
6: private readonly StateStorageTypes storageType = StateStorageTypes.Default;
7:
8: public PageViewStateStorageAttribute(StateStorageTypes stateStorageType)
9: {
10: storageType = stateStorageType;
11: }
12:
13: internal StateStorageTypes StorageType
14: {
15: get { return storageType; }
16: }
17: }
Now all the PageStateAdapter has to do is implement the virtual method GetStatePersister
1: public override PageStatePersister GetStatePersister()
2: {
3: PageViewStateStorageAttribute psa =
4: Attribute.GetCustomAttribute(Page.GetType(), typeof(PageViewStateStorageAttribute), true) as PageViewStateStorageAttribute ??
5: new PageViewStateStorageAttribute(StateStorageTypes.Default);
6:
7: PageStatePersister psp;
8: switch (psa.StorageType)
9: {
10: case StateStorageTypes.Session:
11: psp = new SessionPageStatePersister(Page);
12: break;
13: case StateStorageTypes.InPage:
14: psp = new HiddenFieldPageStatePersister(Page);
15: break;
16: default:
17: psp = new CachePageStatePersister(Page);
18: break;
19: }
20: return psp;
21: }
If a developer wishes to override the (now) default view state persistence method of CachePageStatePersister, they can do so by applying a simple attribute to the page class declaration
1: [PageStateAdapter.PageViewStateStorage(PageStateAdapter.StateStorageTypes.InPage)]
2: public partial class ViewStateInPage : System.Web.UI.Page
3: {
4: }
CachePageStatePersister
The CachePageStatePersister inherits from PageStatePersister. So all it has to do is implement the two virtual methods Load() and Save().
1: public override void Save()
2: {
3: if (ViewState != null || ControlState != null)
4: {
5: if (Page.Session == null)
6: throw new InvalidOperationException("Session is required for CachePageStatePersister (SessionID -> Key)");
7:
8: IStateFormatter frmt = StateFormatter;
9: string state = frmt.Serialize(new Pair(ViewState, ControlState));
10: string sessionId = Page.Session.SessionID;
11: string pageUrl = Page.Request.Path;
12: string vsKey = string.Format("{0}{1}_{2}", VSPREFIX, pageUrl, sessionId);
13:
14: string cachePath = Page.MapPath(CACHEFOLDER);
15: if (!Directory.Exists(cachePath))
16: Directory.CreateDirectory(cachePath);
17:
18: string cacheFile = Path.Combine(cachePath, BuildFileName());
19:
20: using (StreamWriter sw = File.CreateText(cacheFile))
21: sw.Write(state);
22:
23: Page.Cache.Add(vsKey, cacheFile, null, DateTime.Now.AddMinutes(Page.Session.Timeout),
24: Cache.NoSlidingExpiration, CacheItemPriority.Low, ViewStateCacheRemoveCallback);
25: Page.ClientScript.RegisterHiddenField(VSKEY, vsKey);
26: }
27: }
In our save method, you can see we're doing a number of things.. first is generating a unique view state key based on the page that's being requested and the users session id. Then we're serializing BOTH the view state and control state to a physical file (in this case, we're storing the file in the ~/App_Data/Cache directory). After the file is created, we store the view state key, and the path to the file in the Page.Cache, and save the view state key to a hidden field to the page.
So we are mindful of finite server resources by only storing the file path in cache, we are mindful of download page size by only storing the unique key in the page (instead of the entire view state) and by using the Page.Cache object to tie everything together we're giving our persisted view state files a life time. Notice on the last bit of the Page.Cache.Add() method, we're defining ViewStateCacheRemoveCallback as our call back method when an item is removed from cache.
1: public static void ViewStateCacheRemoveCallback(string key, object value, CacheItemRemovedReason reason)
2: {
3: string cacheFile = value as string;
4: if (!string.IsNullOrEmpty(cacheFile))
5: if (File.Exists(cacheFile))
6: File.Delete(cacheFile);
7: }
When the Cache makes the call back, it passes on what that cache object contained, which we know is the file path to the persisted view state object. All we have to do when we receive the call back, is to delete the physical file.
The Load method basically works in reverse of the save method..
1: public override void Load()
2: {
3: if (!Page.IsPostBack) return; // We don't want to load up anything if this is an inital request
4:
5: string vsKey = Page.Request.Form[VSKEY];
6:
7: // Sanity Checks
8: if (string.IsNullOrEmpty(vsKey)) throw new ViewStateException();
9: if (!vsKey.StartsWith(VSPREFIX)) throw new ViewStateException();
10:
11: IStateFormatter frmt = StateFormatter;
12: string state = string.Empty;
13:
14: string fileName = Page.Cache[vsKey] as string;
15: if (!string.IsNullOrEmpty(fileName))
16: if (File.Exists(fileName))
17: using (StreamReader sr = File.OpenText(fileName))
18: state = sr.ReadToEnd();
19:
20: if (string.IsNullOrEmpty(state)) return;
21:
22: Pair statePair = frmt.Deserialize(state) as Pair;
23:
24: if (statePair == null) return;
25:
26: ViewState = statePair.First;
27: ControlState = statePair.Second;
28: }
Some points of interest here are... if the page is not working as a post back, we don't want to load our view state from persistence. This solves the problem of a user loading stale data. The Load() method gets it's view state key from the page, then gets the path to the persisted view state file from the Page.Cache using that view state key. The file is then read, de-serialized into a Pair object, then ViewState and ControlState are loaded from the Pair object.
The Browsers File
The last point of order is to wire up the PageStateAdapter for use with a simple .browser file located in App_Browsers:
1: <browsers>
2: <browser refID="Default">
3: <controlAdapters>
4: <adapter controlType="System.Web.UI.Page" adapterType="PageStateAdapter" />
5: </controlAdapters>
6: </browser>
7: </browsers>
Conclusion
View state, while a powerful tool, can very quickly become the demise of your users in experiencing your web site to the fullest. By making use of built-in ASP.Net technologies like Cache and Control Adapters, we can effectively and accurately persist view state on the server instead of streaming it down to the user. Using the outline PageStateAdapter has enabled our developers to make full use of our existing ASP.Net skill set, including our heavy use of ASP.Net AJAX, without having to re-design our entire page framework; it is truly a drop in solution. Our PageStateAdapter method could be very easily modified to persist the view state files to a common location for a multi-homed web environment, and the Page.Cache replaced with a common mechanism in that same environment. We've been using this technique in our training environment under both real world load and extreme test load with very impressive results.
In our code base, we've also added to the global Application_Start and Application_End events to delete all the .cache files that might be present. This takes care of any artifacts that might be around when the server reboots, or that might have been missed in the cache remove call back.
Download the sample project (11k)