Optimizing ASP.NET Page Load Time

21. January 2012 14:42 by rtur.net in asp.net  //  Tags: , , , ,   //   Comments (0)

Let's start by creating new empty ASP.NET website and adding Default.aspx with minimal “hello world” markup. When you access your site and check it with profiler, you’ll see single get request for default page.

opt1

So far so good, right? Now let's push it a little further by adding couple images and references to 2 styles and 2 scripts. Just enough to make reasonably minimalistic test case. Let's check our new site again.

opt2

Now it looks more interesting. Here what is going on. Browser requests Default.aspx page, IIS constructs it with a help of ASP.NET and passes back HTML markup. Browser parses markup and, as it finds references to external resources, it issues additional requests to grab them. In total in this example we ended up having 7 requests: 1 for page itself, 2 for style sheets, 2 for JavaScripts and 2 for images. Out of the box, it’ll give us miserable 44 out of 100 points with Google speed test. Ouch.

opt5

So we clearly have a problem. Typical modern site often use lots of JavaScripts and style sheets. Number of requests can drag down performance significantly, and we want combine related resources whenever possible. We need a way to combine all styles together and likewise have a single JavaScript file, no matter how many scripts our application really uses.

Here is a plan: we intercept that first Default.aspx request, parse prepared HTML output before sending it to browser and replace all references to JS and CSS with reference to combine resource. So instead of:

<link rel="stylesheet" href="css1.css" type="text/css" />
<link rel="stylesheet" href="css2.css" type="text/css" />
<script src="js1.js" type="text/javascript"></script>
<script src="js2.js" type="text/javascript"></script>

We’ll get this:

<link rel="stylesheet" href="combined.css" type="text/css" />
<script src="combined.js" type="text/javascript"></script>

HTTP module can intercept requests using BeginRequest event handler. Below, code in "Application_BeginRequest" will be triggered when client requests any resource from your application, including .aspx pages. If it is a page, we want stream it back to the browser using our own custom filter.

using System;
using System.Web;

public class OptimizationModule : IHttpModule
{
    public void Init(HttpApplication application)
    {
        application.BeginRequest += (new EventHandler(Application_BeginRequest));
    }

    private void Application_BeginRequest(Object source, EventArgs e)
    {
        HttpApplication application = (HttpApplication)source;
        HttpContext context = application.Context;
        string fileExtension = VirtualPathUtility.GetExtension(context.Request.FilePath);

        if (fileExtension.Equals(".aspx"))
        {
            context.Response.Filter = new WebResourceFilter(context.Response.Filter);
        }
    }

    public void Dispose() { }
}

The concept of Response.Filter might be a little hard to grasp, good overview you can find here. Idea is to provide custom implementation of stream that will be passed down to browser instead of one built by ASP.NET engine. The only interesting part there is Write method, where we can get a hold on HTML about to be sent to client and modify it. In our case, we parse HTML looking for any JavaScript and CSS references, save them all into cache and replace them with reference to combined resources we’ll build on the fly later. Combined style reference we stick where we found first CSS style and script reference just before the "</body>" tag.

public override void Write(byte[] buffer, int offset, int count)
{
	var html = Encoding.UTF8.GetString(buffer, offset, count);

	var scriptMatches = Regex.Matches(html, @"\<script.+src=.+(\.js|\.axd).+(</script>|>)");
	var styleMatches = Regex.Matches(html, @"\<link[^>]+href=[^>]+(\.css)[^>]+>");

	if (scriptMatches.Count > 0)
	{
		foreach (Match match in scriptMatches)
		{
			html = html.Replace(match.Value, "");
			Cache.AddScript(match.Value);
		}
	}

	if (html.Contains("</body>"))
	{
		html = html.Insert(html.IndexOf("</body>"),
			"<script src=\"combined.js\" type=\"text/javascript\" defer=\"defer\" async=\"async\"></script>" +
			Environment.NewLine);
	}

	if (styleMatches.Count > 0)
	{
		int idx = 0;
		foreach (Match match in styleMatches)
		{
			idx = idx > 0 ? idx : html.IndexOf(match.Value);
			html = html.Replace(match.Value, "");
			Cache.AddStyle(match.Value);
		}

		html = html.Insert(idx, 
			"<link rel=\"stylesheet\" href=\"combined.css\" type=\"text/css\" />" +
			Environment.NewLine);
	}

	var outdata = Encoding.UTF8.GetBytes(html);
	this.sink.Write(outdata, 0, outdata.GetLength(0));
}

The cache implementation is dead simple, it only has two lists to keep scripts and styles removed from HTML markup.

using System;
using System.Collections.Generic;

public class Cache
{
    public static List<String> Scripts { get; set; }
    public static List<String> Styles { get; set; }

    public static void AddScript(string s)
    {
        if (Scripts == null)
            Scripts = new List<string>();

        if (!Scripts.Contains(s))
            Scripts.Add(s);
    }

    public static void AddStyle(string s)
    {
        if (Styles == null)
            Styles = new List<string>();

        if (!Styles.Contains(s))
            Styles.Add(s);
    }
}

When modified HTML will be sent to browser, it'll find "combined" references and issue requests to get them. Obviously, there are no physical files for IIS to send. But we can take care of it by plugging in HttpHandler that will listen for requests made to get .js and .css files and handle them appropriately. Here is JavaScript handler.

using System;
using System.Web;
using System.IO.Compression;

public class ScriptHandler : IHttpHandler
{
    public bool IsReusable { get { return false; } }

    public void ProcessRequest(HttpContext context)
    {
        if (Cache.Scripts != null && Cache.Scripts.Count > 0)
        {
            string s = "";
            foreach (var src in Cache.Scripts)
            {
                s += ScriptResolver.GetLocalScript(GetFileName(src));
            }
            s = Compressor.Minify(s);
            Compressor.Compress(context);
            context.Response.Write(s);
        }
    }

    string GetFileName(string src)
    {
        int start = src.IndexOf("src=") + 5;
        int end = src.IndexOf(".js") + 3;
        return src.Substring(start, end - start);
    }
}

As you can see, it looks up that cached list of removed .js references and goes through them, reading each .js file and combining all scripts into one big string. ScriptResolver just opens and reads file from disk, nothing interesting. Then using Response.Write handler will stream resulting string to the client instead of passing back not-existing "combined.js" file that browser asked for. Before sending, it will use Compressor to minify string and compress response. Our compressor is not too complicated:

using System;
using System.Web;
using System.Text;
using System.Text.RegularExpressions;
using System.IO.Compression;

public class Compressor
{
    public static void Compress(HttpContext context)
    {
        if (IsEncodingAccepted("gzip"))
        {
            context.Response.Filter = new GZipStream(context.Response.Filter, CompressionMode.Compress);
            SetEncoding("gzip");
        }
        else if (IsEncodingAccepted("deflate"))
        {
            context.Response.Filter = new DeflateStream(context.Response.Filter, CompressionMode.Compress);
            SetEncoding("deflate");
        }
    }

    public static string Minify(string body)
    {
        string[] lines = body.Split(new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
        StringBuilder emptyLines = new StringBuilder();
        foreach (string line in lines)
        {
            string s = line.Trim();
            if (s.Length > 0 && !s.StartsWith("//"))
                emptyLines.AppendLine(s.Trim());
        }

        body = emptyLines.ToString();

        // remove C styles comments
        body = Regex.Replace(body, "/\\*.*?\\*/", String.Empty, RegexOptions.Compiled | RegexOptions.Singleline);
        //// trim left
        body = Regex.Replace(body, "^\\s*", String.Empty, RegexOptions.Compiled | RegexOptions.Multiline);
        //// trim right
        body = Regex.Replace(body, "\\s*[\\r\\n]", "\r\n", RegexOptions.Compiled | RegexOptions.ECMAScript);
        // remove whitespace beside of left curly braced
        body = Regex.Replace(body, "\\s*{\\s*", "{", RegexOptions.Compiled | RegexOptions.ECMAScript);
        // remove whitespace beside of coma
        body = Regex.Replace(body, "\\s*,\\s*", ",", RegexOptions.Compiled | RegexOptions.ECMAScript);
        // remove whitespace beside of semicolon
        body = Regex.Replace(body, "\\s*;\\s*", ";", RegexOptions.Compiled | RegexOptions.ECMAScript);
        // remove newline after keywords
        body = Regex.Replace(body, "\\r\\n(?<=\\b(abstract|boolean|break|byte|case|catch|char|class|const|continue|default|delete|do|double|else|extends|false|final|finally|float|for|function|goto|if|implements|import|in|instanceof|int|interface|long|native|new|null|package|private|protected|public|return|short|static|super|switch|synchronized|this|throw|throws|transient|true|try|typeof|var|void|while|with)\\r\\n)", " ", RegexOptions.Compiled | RegexOptions.ECMAScript);

        return body;
    }

    private static bool IsEncodingAccepted(string encoding)
    {
        return HttpContext.Current.Request.Headers["Accept-encoding"] != null && 
            HttpContext.Current.Request.Headers["Accept-encoding"].Contains(encoding);
    }

    private static void SetEncoding(string encoding)
    {
        HttpContext.Current.Response.AppendHeader("Content-encoding", encoding);
    }
}

It has home-brewed Minify function (demo replacement for Ajax.Minifier) to make scripts meaner and leaner and Compress method to "gzip" response that should shrink styles even more to save bandwidth. With all that taken care of, our end result should look something like this:

profiler-compressed

Here we are, going from 134.4KB to 51.2KB in size and saving browser two round-trips. No, this sure won’t score 100 as we didn't take care of image optimization, browser caching, setting appropriate HTTP headers etc - but that's ok and by the way even this bare-boned solution took me from 44 up to 86 points at page speed test. This code is intentionally simplistic and doesn’t take into account age cases, error handling etc. This is for clarity and to better represent concepts of combining, minifying and compressing in general. You can download project from link below and run it in Visual Web Developer, WebMatrix or as IIS application. It is very little code that is easy to follow.

JUST DON'T USE IT AS PRODUCTION-READY CODE!

Because it is not :) At least not yet, I'm working on possibly utilizing it for BlogEngine.NET and will publish more solid version later. Current code is for demo purposes only, it lacks tons of things you would absolutely require in your real-world application and makes way too many bold assumptions. But with all those things taking most of the space it would be a lot harder to understand workflow I really wanted to focus on.

Demo1.zip (57.82 kb)

IIS 7 – Who is running the show?

30. October 2009 22:00 by rtur.net in asp.net  //  Tags: ,   //   Comments (8)

iis7

When you run ASP.NET site in Visual Studio things generally tend to work. It usually when you try to deploy it to live server when you get into trouble. This is why I wasn’t surprised when after setting up little continuous integration server my application that ran perfectly well in VS broke apart on local IIS 7. I went to IIS console and double-checked application settings and write permissions. Along with Network Service I gave access to ASPNET account, because IIS keeps changing identity and I felt lazy to figure out what account it uses in this particular case. More...

Using machineKey with ASP.NET Membership

30. March 2009 20:42 by rtur.net in asp.net, Security  //  Tags: ,   //   Comments (4)

mbrship-1Either you run your web site in the shared hosting environment or on your local IIS server, you likely have several ASP.NET applications running in the same root directory. Each of them can be configured as a separate web application and run totally independent from others. Although BlogEngine is not (yet) multi-blogging platform, you can easily run bunch of BlogEngins on the same root for number of bloggers. Lets say, you have 3 bloggers contributing to your site and you want each of them have their very own blog, then you create similar structure: More...

Dynamic compilation in ASP.NET

28. May 2008 12:38 by rtur.net in asp.net, Code Snippets  //  Tags: ,   //   Comments (2)

We all know about magic App_Code folder. Just drop class file in there and it will become a part of the web application. This is fine for scripts like PHP or "classic" ASP (VB script), but C# is strongly typed compiled language. How App_Code works? As any magic, mostly smoke and mirrors. Behind the scene, ASP.NET will create App_Code.dll and merge it with main application assembly at run time. This simple trick gives us best of both worlds - dynamism of scripting languages (ok, to the point) and all the good stuff coming with strong typing and compilation (whatever they are). More...

Strategy pattern in C#

2. March 2008 19:09 by rtur.net in asp.net, General  //  Tags: ,   //   Comments (0)

dp-3

Do you use design patterns in your daily development? You probably should, and if you don't you might start with reading some books on the subject. I would suggest one from Head First series, although not everybody is a big fan of this book. But I found it fun and easy reading that can trigger your curiosity and encourage you to dig dipper. It is written for Java developers, but language samples presented in the book are minimal and, if you don't understand Java, you can refer to this project for C# translation. More...

Doing Ajax using client callbacks

21. November 2007 14:09 by rtur.net in BlogEngine, asp.net, Ajax  //  Tags: , , ,   //   Comments (2)

Yes, I've heard about Ajax before - one would have to be hiding in the hole for the last year or two to avoid the buzz. I've read articles, seen videos and presentations, even used applications that utilize Ajax on a daily basis. So I’m not exactly a newbie. But somehow I managed to stay away from it - no projects I've been involved into for the last few years used Ajax. I decided it is a shame and I want to change it. Here is a plan: for starters I'll write two small applications ("gadgets", or user controls, for BlogEngine). The first one will be using "classic" JavaScript callback approach and the other one will be doing similar stuff the Microsoft way. Then I will compare experience. Sounds fair? More...

VS 2008 and .NET 3.5 are out

20. November 2007 10:53 by rtur.net in asp.net  //  Tags:   //   Comments (2)

Every time someone tells how powerful today’s computers are and how one should always trade performance for good architecture, testability and thousand other great things - I want to hit guy on the head. And I'm not a violent person. It just sucks to right click in the windows explorer and wait 20 seconds before context menu shows up or get "calculating remaining time" when trying to copy files around (yes, I'm still getting those in my all powerful Vista occasionally). Apparently, today’s computers have so much power that they can't concentrate on such minuscule tasks, and processor has to do complicated calculations in the background just to keep itself entertained. More...

Keeping track on your downloads

17. October 2007 09:28 by rtur.net in BlogEngine, asp.net  //  Tags: ,   //   Comments (3)

When you provide downloadable content on your blog, you might want to keep track on downloads. With BlogEngine, it is incredibly easy to do – all you need is subscribe to one of the events exposed by core framework and log download information to any medium you like. For example, you want to log it in the tab delimited flat file so it is easy to export to Excel and do whatever kind of reporting you want to do.

First thing, we need More...

SMTP with GoDaddy

12. October 2007 11:19 by rtur.net in asp.net  //  Tags: , ,   //   Comments (14)

That is amazing how easy it is to fix any problem as soon as it gets to the status of the ex-problem. After I found the way to make my email work on GoDaddy with their Form Mailer, I took another look at SMTP and sure enough all peaces readily fall into place. Here is step-by-step instruction on how to setup and use SMTP with GoDaddy – the way God intended. More...

Make GoDaddy to send your mail

10. October 2007 15:27 by rtur.net in asp.net  //  Tags: , , ,   //   Comments (16)

Remember those lazy days when you could kick your trustworthy CDONTS mail messages from anywhere inside juicy spaghetti code and it just worked? Well, those days are over. Even 1.x’s System.Web.Mail – also not a brainier to use – is outdated and big no-no our days.

Now we supposed to use [W:SMTP]. And sometimes this beast just refuses to cooperate. Complete showstopper. I’m not a quitter, but I’m not going to spend countless hours googling around for the fix either (although, I admit, it is kind of addictive). So after short hunt, when first four or five “guaranteed” solutions did not work out I started looking for workaround. More...

Recent Comments

Comment RSS