Blog icon

Lessons learnt by building Single Page Applications

By Sebastian Porto,
Sebastian Porto
Scroll down to read

The last 5 years has seen an explosion of rich front-end applications (also referred as SPA - Single Page Application) and during this time I have had the luck of working on many projects like this.

I have worked on small single page apps and a few big ones too. Built with many different things like Adobe Flex, jQuery, Backbone, Angular, CanJS. Thus I believe I have a bit of experience to share.

I want to share some of the lessons I have learnt building them, especially what things I found difficult technically. Based on my experience I think the following are common issues that you may encounter when building them.

What is a large SPA?

When I refer to a large single page application I have in mind a single application that does many different things, for example: Show a dashboard, manage customers, orders, products, shipments, show reports, etc.

An application that shares a backend but is composed of multiple rich pages is not a large SPA.

Consistent State is hard

Imagine the following:

  • We fetch some statistics from the server and display them somewhere in our application
  • The user later makes a changes somewhere else e.g. edits a record
  • This change is somehow connected to the information in the stats that we retrieved earlier

Sooner or later users will expect to see the stats updated due to the user's interaction. Meaning they expect a consistent state. For a small application this may not a big problem as we can usually keep these relationships in our head and know when we need to update them.

But for bigger applications this is quite hard. Our application can get into an inconsistent state rather quickly. Compound that with the issue that another user might make a change that our application needs to reflect.

Keeping a consistent state between the server and our application is a difficult challenge.

Strategies

Trying to be diligent and synchronise and manage all this state ourselves is difficult and can get out of hand quite easily in a big application. Yes, it can be done but don't expect it to be easy. In a small app this may be manageable.

  1. Frameworks. Maybe using a framework that promises to do this for you e.g. Meteor could be a solution, but personally I have my doubts about them for large apps. I'm not sure what would happen when you have dozens of collections and objects that need to be shown and kept synchronised.

  2. Avoid. The only good solution I have found is to avoid building large apps altogether. Building a collection of smaller apps can help us to avoid this problem entirely, getting the user to refresh their browser every once in a while goes a long way to avoid the consistency problem.

Memory leaks

Memory leaks can be a big problem, depending on the usage pattern of your application. If you have a long lived single page app then it is likely that you will run into memory leak issues.

It is actually quite easy to introduce issues like these, for example:

  • We add a view to the DOM
  • In that view we listen to some event, e.g. window.resize
  • Then we remove the view and replace it with something else

We now have a memory leak, we forgot to unbind the 'window.resize' event. Now we have a JavaScript object that is still listening to the event but we don't have access to this object anymore.

Apart from memory issues we end up with strange bugs as we have 'zombie' listeners i.e. multiple objects responding to the same events.

Dealing with them

Some possible solutions to deal with memory leaks and zombie listeners are:

  1. Being diligent. For example we need to make sure that when we remove an object from the DOM we properly unbind all the events and kill any intervals. Of course this is a lot easier said than done.

  2. Frameworks. Use a framework that make this easier by doing some of the hard work. For example CanJS will call a destroy method automatically in our controls when they are removed from the DOM, in that method we have the opportunity to unbind any events we added manually. So learn how your framework does it and follow their recommended patterns.

  3. Avoid them. Lastly, a very effective way of dealing with memory leaks is to avoid building applications that require a page to be up for a long time. There is nothing like a page refresh to free up memory and remove unwanted listeners.

Reactive views

With the popularity of jQuery it is has become very common to manipulate the DOM directly, e.g. showing, hiding or inserting elements as needed. Another common approach is to render a template and update the DOM with the output each time there is a change.

These approaches are ok for some kind of apps, but they can become a burden and a source of bugs for applications that require heavy user interaction.

For example, this is a common scenario:

  • We render a view using a template
  • User interacts with the view
  • We capture the user input
  • Then we want to make changes in the UI, without affecting the user's input

In that case we cannot just re-render the template, because we may wipe out the user's input. We can manipulate the DOM manually, but this usually ends up in tangled and difficult to maintain code.

Doing it better

The solution is simple, use some kind of view-binding library, there are many: Angular, Ember, CanJS, React, Knockout. Just don't do things manually, we don't even need two way bindings, as long as we have something that will take care of pushing the changes to the DOM automatically (and selectively), our code will be much clearer and easy to understand.

Here is a simple example using Angular bindings:

    angular.controller('Controller, function ($scope) {
        $scope.title = 'Title';
    });
    <div ng-controller='Controller'>
        <h1>{{title}}</h1>
    </div>

Code organisation

Unless we are doing a very small application we are likely to spread our code among multiple files and organise them in folders.

It is likely that at some point we will have code that depends on another piece of code. For example we might have something like this:

    APP.Car = App.Vehicle.extend(...)

This code expects to find an App.Vehicle object. So the loading order of our files matter (car.js will be loaded before vehicle.js). We don't want to manage this loading order by hand, this is a nightmare for a non-trivial application. Namespacing our objects is not enough for dealing with this problem.

Alternatives:

Angular

If we are using Angular, then this problem is solved for us, the Angular module system takes care of removing the loading order problem entirely.

Require.JS (AMD)

Require.js is great for organising our code into small modules and just loading the code that we need, in the order we need. Require JS also have an optimisation tool that will let us 'compile' the code into one file for production.

ModuleJS

Another interesting solution is ModuleJS. If we are using something like Ruby on Rails we probably don't want to bring in RequireJS, as Rails solves the problem of loading and optimising assets in a different way, using Require JS with Rails is double handling. ModuleJS is a very interesting solution, it allows us to remove the loading order problem without having to use AMD.

modulejs.define('main', ['jquery', 'foo'], function ($, foo) {
    // do something ...
});

There is also browserify, but I haven't had the opportunity of using it.

Death by loaders

Frameworks like the ones mentioned above make it very easy to create lots of little components that fetch their own data from the server. This seems like a great idea but it is very easy to end up with an application that looks like this:

So instead of having a page that takes a while to load we end up with a page that loads very fast but shows lots of loaders, this is a terrible user experience.

Some solutions

  1. Test in slow connections. We tend to just test our work in our local development environment which will usually be very fast. Charles is a great tool for simulating slow connections and experience what our users will see.

  2. Avoid doing too much in each request. One really bad thing we can do is to have very slow responses from the server, usually this happens as we want to load lots of information on each request (e.g. stats about a record that require joins in the db). Avoid this, try to only load the minimum amount necessary and then load the rest upon user's request. Even then, making lots of requests in a single screen is not optimal.

  3. Pack data. Bundle the data needed for one screen in as few requests as possible. Instead of making lots of requests to load the data needed for one screen we could try to pack all the information together. The typical example is a dashboard with lots of widgets. Instead of having each widget request the necessary data we could just do one request that fetches all the data necessary for the whole dashboard. This is the thing that will probably have the biggest impact on the user experience.

We are too optimistic

When we develop single page applications we need to consider that JavaScript is a very loose language, it doesn't break when it should (but our application does) and we need to code for many different environments (browsers). Very often we are too optimistic in the way we do things, for example consider this code:

something: function (args) {
    var res = somethingElse(args);
    ...
}

We are just passing args to the inner function, we don't even know what it is and don't seem to care.

Here is another example of a 'clever' one liner, there are so many things that can go wrong:

someArray[parseInt(someOther.indexOf(foo), 10)]

In the backend these kind of things are not so terrible as we control our environment and we can usually get a good stack trace, but in the browser this is much more difficult. We usually end up with errors like:

window.onerror
'undefined' is null or not an object

Code defensively

We can avoid this by writing a bit more code and stop being so optimistic, it will almost inevitably break!

  • Guard your functions. Adding some checks to the entry points of our APIs is a good idea, don't let errors work their way too deep into the stack,
  • Throw errors early. Don't be afraid to throw an error if you get a bad input, it is better if you throw early than having a weird error we can't understand somewhere in a library stack trace.

For example: js something: function (args) { if (!args.foo) throw new Error('Expected foo'); var res = somethingElse(args); ... }

Feedback is very important

This seems really obvious, but I have seen many applications where this is completely forgotten. Again we are too optimistic, we incorrectly think that the internet is fast and errors don't happen.

A common example I have seen (and written) many times is something like this:

foo.save().
    then(function () {
        // do something when saved
    });

Here we are:

  • Forgetting to show some indicator when our application is busy doing an AJAX request.
  • Forgetting to do something on failure

Solutions

  1. Always provide feedback. E.g. disable the button, show a loader, etc. This is very easy to do if we are using reactive views.

  2. Never ignore failure. We should always consider what will happen if something fails, for example provide a message and revert the view to a state where the user can do something. Don't just ignore this!

Don't lock yourself in

Technology changes a lot, especially in the JavaScript world, I have learnt to be very careful about locking myself into one solution. Even if we are confident that our chosen framework is solid and future proof, we will often find cleaner and improved ways to solve problems.

I have worked on a few large single page applications that used particular patterns that were far from perfect, these things are not obvious at the beginning and we accept that, but they can be quite hard to correct in a monolithic application later on.

This is not to say that we shouldn't use a framework. I believe the best approach is to allow room for change by dividing our applications into smaller ones. So again, avoid big single page applications.

Keep your stack simple

Bugs happen, this is unavoidable, but the less the better. Having many layers of abstraction in our stack can be a source of small bugs that are difficult to track.

For example, in one project we were using hamlc and the indentation of a line was mistakenly wrong. The application wouldn't compile because of this. Finding the source of that problem was a big waste of time.

I recommend only adding abstractions that provide significant value. E.g. Does CoffeeScript really make things substantially better? If so, definitely use it, otherwise keep it simple.

Think about maintenance

Many projects need to be maintained and enhanced for a long time, and as developers come and go and the new shiny code of today will be the legacy code of tomorrow that nobody wants to touch.

My experience is that building good maintainable SPAs require highly capable developers in the framework of your choosing. Flexible tools are great, but they come with a big trade off, the more flexible they are the higher the entry barrier for new developers coming to the project.

  • Ember. Ember deserves a special mention here as it is based on very strong conventions. The main benefits is that a new developer can come into a project and quickly find where things are and how they work together.

Conclusions

... you can write large programs in JavaScript. You just can’t maintain them.

-- Anders Hejlsberg - Lead architect C# and Typescript

The secret to building large apps is never build large apps.

-- Justin Meyer, author JavaScriptMVC

If I have one recommendation from my experience after all these years, it is: Don't build large single page apps, build a collection of small ones instead. This is the single most important strategy that will help you to avoid a lot of issues in the future.

So instead of building something like this:

We have several smaller apps that deal with less things:

blog comments powered by Disqus