Development teams often are pushed by the business to deliver new functionality as soon as possible to keep ahead of the competition. Teams feel responsible and communicate to the business a certain amount of functionality can be realized that adheres to certain quality standards. In practice, unfortunately, there are times when this is overruled by the business at the expense of quality, i.e.: more functionality, less quality and more technical debt. As a consequence, bad performance, bugs and service outage could occur. This builds up to a point where end users are affected, and the business cannot ignore these issues anymore.
I have selected three issues that impact performance:
- Serial request execution
- Chatty client
- Large image size
These issues were encountered on a live customer portal and how to tackle them. The customer portal allows registered users to login and provides a dashboard to view statistics specific to them. The data to generate these statistics is retrieved from backend systems.
Serial request execution
The best fixes are those that have a low impact on the landscape while yielding great results. To a certain extent, the technology stack I encountered consists of an Angular front end, an Azure middleware .NET API and an on-premise backend database system. This means, a great deal of logging is done in Azure Application Insights and this is a good starting point in analyzing application performance.
In the end-to-end trace above, the query string part (not depicted here) of the five Details calls is almost identical, which raises the question: “Can’t we combine it into one call?”. Although this is possible, after analyzing the code and talking to domain experts a solution with less impact on the current application landscape has been chosen. This solution answers another question that may arise by observing the trace: Do similar calls need to wait for each other?
When examining the code, it turns out that a part of it doesn’t need to. The first await statement has no dependencies between offers in the foreach loop.
await frontendResponse.Data.Offers.ForEachAsync(async offer => { offer.ContentData = new ProductContentData(); await offer.Contracts.ForEachAsync(async duration => { // only get the content for the 1st duration if (offer.ContentData.BundleDescription == null) { // . . .
The ForEachAsync is done serially. This is shown next, where an action only proceeds to the next one after the action is completed.
static async Task ForEachAsync<T>(this IEnumerable<T> list, Func<T, Task> action) { foreach (var item in list) await action(item); }
In the suggested improvement the actions for the first await statement are executed in parallel. To yield the same output we need to keep the second await statement serial, because of the if statement. Otherwise the if statement will always be true. An improvement would be to remove this dependency on ContentData.
await frontendResponse.Data.Offers.ForEachParallelAsync(async offer => { offer.ContentData = new ProductContentData(); await offer.Contracts.ForEachAsync(async duration => { // only get the content for the 1st duration if (offer.ContentData.BundleDescription == null) { // . . . static Task ForEachParallelAsync<T>(this IEnumerable<T> list, Func<T, Task> action) { return Task.WhenAll( from item in list select Task.Run(() => action(item)) ); }
The ForEachParallelAsync method is added to run tasks in parallel. This is done with the Task.Run(() => action(item))
command. This queues the tasks on a thread pool. The Task.WhenAll
method creates a new task that completes when all the provided tasks have completed.
The resulting performance gain is dependent on the amount of offers. Usually there are multiple offers. The response time has been measured with SoapUI and on average is cut in half:
Chatty client
When navigating on the customer portal and inspecting the network trace a lot of traffic can be identified on each navigation click.
This is fine when the navigation path has not been clicked before, but not when this is repeated in the same session. The response time is fairly quick (<350 ms as shown above) as the data is fetched from an Azure Table Storage cache. On the other hand, the data that is transferred increases and makes the client chatty. This means that it could lead to unnecessary waiting time on slow connections and an increase in data transfer costs on devices with a metered connection. Furthermore, to realize caching in Azure Table Storage, a fair amount of custom code has been written that could make it difficult to maintain over time.
Multiple approaches can be identified to improve the caching mechanism. The most prominent are browser caching with AngularJS $cacheFactory and HTTP caching with ETags. I will zoom in on the former, since this method is currently being implemented for the customer portal. It is a lightweight solution and fulfills the requirements for caching on the customer portal.
With the AngularJS $cacheFactory approach, caching is done in AngularJS on the JavaScript heap memory and lasts for the duration of the session (until page refresh). It is possible to specify caching per request. It is not possible, by default, to specify a cache expiration window. There are libraries available to realize this, such as the Angular-cache. The implementation is done in the front-end code and is fully supported by AngularJS. The client remains in control and is able to flush the cache by refreshing the page.
An example of the newly added code (highlighted below) is shown below:
return $resource(endpoint.url, { PersonId: function() { return service.PersonID; }, PartnerId: function() { return service.PartnerID; }, AccountId: function() { return service.AccountID; } }, { ‘get’: { // method:’GET’, // added code cache: true // } });
Only GET (and JSONP) methods can be cached and above this is controlled per request. It can also be applied application wide by using a configuration file.
The result of this would be:
First time calls are left out in the screenshot. No subsequent calls to services are made, which makes the client less chatty. I.e.: fewer requests and fewer transferred bytes between client and server.
Large image size
A great tool to generate performance audits for a website is Google Lighthouse. This tool comes as part of Google Chrome and can be run from the Audits tab in the developer toolbar.
One of the main bottlenecks on our website is the image size:
The image in the screenshot is retrieved from the server as a 2400 by 1122 pixels image, after which it is scaled down in AngularJS to 248 by 244 pixels. As advised by the Lighthouse report, if we would serve a smaller image the potential savings could be 83%. Images are served through EPiServer, which is a content management system. We could simply ask a content manager to replace the original images with smaller versions of them, but if they are used elsewhere, this could impact other consuming applications as well. A better approach would be to let the AngularJS application request a smaller image from the original one. Within EPiServer an image resizer is available and needs a query stringas input.
The original GET request http://EPiServer/myimage.jpg
can be replaced by http://EPiServer/myimage.jpg?height=300&-mode=crop&quality=75&anchor=bottomright<code>.
The result is as follows:
The savings are approximately 81% and we still have an image with acceptable quality.
This can be deployed to production and will probably work. However, from a quality perspective we need to test this locally to proceed down the deployment environments and assuming we don’t have EPiServer installed, this could be an issue. The image resizing functionality in EPiServer needs to be mimicked somehow in order to show that this change works. The first part of the solution is to enhance BrowserSync. BrowserSync keeps the AngularJS application in sync with the source code while serving it. It contains a middleware option in which server-side functionality can be defined. The second part is a library that does the image resizing for us. I used the sharp from lovell, which can be found on GitHub. The following code as part of the BrowserSync init function is added:
middleware: [ //This function is mimics episerver’s image resize function function (req, res, next) { let anchorMapping = { ‘bottomright’: sharp.gravity.southeast }; let requestUrl = url.parse(req.url, true); let queryObject = requestUrl.query; if (queryObject.width || queryObject.height) { //Removes leading slash, otherwise: image not found let resizedImage = sharp(requestUrl.pathname. replace(/^\/+/g, ‘’)) .resize(parseInt(queryObject.width) || null, parseInt(queryObject.height) || null) .crop(queryObject.anchor ? anchorMapping [queryObject.anchor] : sharp.gravity.topleft) .jpeg({ quality: queryObject.quality ? parseInt(queryObject.quality) || 80 : 80 }); res.writeHead(200, { ‘Content-Type’: ‘image/jpeg’ }); resizedImage.pipe(res); } else { next(); } } ]
The anchorMapping is a key value object that maps the value of the query string parameter “anchor” to Sharp’s definition of anchoring cropped images. For this example, only one key is defined. The request URL is parsed to extract the query string parameters and values. The Sharp resizing function is then called with these values.
The regular expression removes a forward slash from the pathname. In this case, the images are located on /images/. When leaving the leading slash in place the gulp serve command (which calls this code) will throw an error, because it cannot find the image. The content type in the response header is set to an image and the resized image stream is piped to the result stream so the body of the response will contain the resized image. Finally, the next() function is needed to let all other requests pass through.
The result is as follows:
The height can be changed while the application is running and when reloading the page this yields:
Conclusion
Poor product quality can lead to performance degradation up to a point where the customer is affected. Different tooling has been used to test website performance and pinpoint bottlenecks. Several best practice implementations that have a low impact on the existing landscape and can be introduced quickly have been shown. The resulting increase in performance is significant.
Although these solutions improve customer experience for the near future, there is more to a good performing application. How can we make sure that teams become more self-controlled, stop building up technical debt and really help the customer? This is the root cause and it involves company culture and the willingness to practice true agile principles. I’ll gladly hear your thoughts on this.
This article is part of our latest magazine; XPRT.#6 Download it here or get your free copy./>