Long Life Web Apps
Our battle against the browser
by Matt Andrews, FT Labs (mattandre.ws / @andrewsmatt )
Good hacks and great performance, smart multivariate responsiveness, and web apps that deserve far more than the lowly title of ‘page’.
Benefits
No review / approval process & automatic updates
Instant install
One app, many platforms
Direct relationship with readers
Apple don't take 30% cut
Native-ish: homescreen icon, swiping, offline, fast (enough)
The catch?
They're really difficult to make well :(
Our web apps
Single page app
'Many page app' - website?
Cross platform - 1 codebase for all
Offline first
Time on page very long (up to days)
Once installed, code on device forever
I'm a developer on the FT Web app, which is a single page web app that works offline across all major mobile and desktop browsers - creating a full-screen native-app like experience without having to be distributed through any app stores. I'm going to talk through some of the challenges we faced when building this sort of website.
Now need to think much more about:
Memory management and performance
Handling link clicks
Caching
Backwards compatibility
As a web developer it's really easy to forget how powerful the browser actually is - and how much work it does for you. Unlike traditional software development when you make a webpage you normally don't need to worry about memory management or garbage collection or backwards compatability - even if you do break something, most mistakes can be fixed by the user simply clicking refresh.
Memory Management & Performance
OK so this isn't really an issue that is new to this class of website - but our websites run for hours or for packaged apps - sometimes even days - so performance is critical.
The browser has a very good garbage collector - and when you're making normal webpages you probably never need to think about it. But when you have long lived pages - with user session times reaching dozens of minutes or longer, it can cause a degraded experience - and we suspect it is the cause of some of the crashes our application experiences.
Detached DOM
- Some javascript libraries, such as jQuery, keep a cache of DOM nodes - so if you're using jQuery and want to remove an element from the page you *must* use jQuery's APIs otherwise that cache won't know it needs to update and you'll get a memory leak.
- But it's easy to end up with this sort of memory leak in application code too - espeically in apps where you're constantly building and tearing down pages. The first version of the FT web app was riddled with them.
- Luckily Chrome Dev Tools have some quite powerful and relatively easy to use tools for detecting them.
How to find it
Chrome DevTools » Profiles » Heap Snapshot
Filter by detached to see the node.
Hover the results to get clues about which objects have become detached
ProTip: Profile; do one action ; Profile again - and use the comparison view (filtered by 'detached') to see if that action caused a leak
Avoid Memory Leaks
Tidy up after yourself - eg. addEventListener
» removeEventListener
Check 3rd party components tidy up after themselves on destroy
Be careful with jQuery - it keeps caches to DOM nodes.Don't mix jQuery DOM methods with native browser methods / libs .
Minimise the number of objects added to global scope.
Fix memory leaks to
increase stability (but very difficult to measure - unlike native apps)
allow the garbage collector to do its job
but for performance, our problems are usually with rendering
Using Timeline
Feel like it's just telling me I'm doing everything wrong
I want a timeline that looks like this:
Don’t use your mouse when Timeline profiling. Give elements IDs and trigger the events on them that you want to profile via the console
Layout Thrashing
DOM operations are synchronous but 'lazy' by default
Browser will batch writes for you
But you force it to write if you try to read something
Interleaved reads/writes
var h1 = element1.clientHeight; // Read (measures the element)
element1.style.height = (h1 * 2) + 'px'; // Write (invalidates current layout)
var h2 = element2.clientHeight; // Read (measure again, so must trigger layout)
element2.style.height = (h1 * 2) + 'px'; // Write (invalidates current layout)
var h3 = element3.clientHeight; // Read (measure again, so must trigger layout)
element3.style.height = (h3 * 2) + 'px'; // Write (invalidates current layout)
etc.
Batching reads/writes manually
var h1 = element1.clientHeight; // Read
var h2 = element2.clientHeight; // Read
var h3 = element3.clientHeight; // Read
element1.style.height = (h1 * 2) + 'px'; // Write (invalidates current layout)
element2.style.height = (h1 * 2) + 'px'; // Write (layout already invalidated)
element3.style.height = (h3 * 2) + 'px'; // Write (layout already invalidated)
h3 = element3.clientHeight // Read (triggers layout)
etc.
Nobody writes code like that
Dynamic multi-line ellipsis
this doesn't exist in CSS
github.com/ftlabs/ftellipsis
Asynchronous DOM?
Use Wilson's FastDOM library to get asynchronous DOM today.
fastdom.read(function() {
var h1 = element1.clientHeight;
fastdom.write(function() {
element1.style.height = (h1 * 2) + 'px';
});
});
fastdom.read(function() {
var h2 = element2.clientHeight;
fastdom.write(function() {
element2.style.height = (h1 * 2) + 'px';
});
});
This works by using requestAnimationFrame
to batch writes
Recap
Use heap profiler to uncover memory leaks
Batch DOM read and writes to avoid layout thrashing
… but don't take my word on any of this. Measure for your application.
Handling Links in a single page app
Should be easy, right?
The Challenge
When a user clicks on a link on a full screen iOS web app:
Your web app closes « Better this if didn't happen
The link opens in Safari
Handling links?
Listen to clicks, and preventDefault()
Update the URL bar: history.pushState()
Load/display the new page.
Go back with history.back()
'After going back I wasn't where I was before I clicked the link'*
Listen to clicks, and preventDefault()
Save scroll position for current page
Update the URL bar: history.pushState()
Load/display the new page
Restore scroll position if appropriate
Go back with history.back()
* Same is true for web forms
- Even simple things like when you click a link, the browser will wipe the slate clean and the next page will be loaded fresh (it will even remember details like how far down they have scrolled on the page they clicked from so that if the user clicks back it will restore their scroll position). When you create a single page app and start relieving the browser of the responsibility of things as simple as 'handling links' - all those little things the browser does for you become your responsibility.
And again for iOS 7
Listen to clicks, and preventDefault()
Save scroll position for current page
Update the URL bar: history.pushState()
Or on iOS 7 push the new URL into a custom history array
Load/display the new page.
Or on iOS 7 pop the last item from the custom history array & load it
Restore scroll position if appropriate
Go back with history.back()
…until a journalist embeds a Tweet
Twitter library listens to all clicks on host page
If URL matches twitter.com/intent it opens in a new window (Reminder: on iOS this will close your web app)
(Also an issue on packaged apps - eg. Windows 8 Web Apps, Chrome packaged apps, hybrid apps)
I'm being unfair to Twitter, lots of libraries have problems.
Some ad libs wait for DOMContentReady
- usually happened a long time ago
document.write
isn't going to work.
Analytics libraries lose data when device is offline
If you’re building websites in non-standard ways (full screen ‘web apps’; packaged/hybrid apps; single page apps and/or offline first apps) don’t automatically assume that because you’re using ‘web technologies’ you will be able to use every existing library that was built for the web. All libraries – even modern, well written ones like the one Twitter use for embedding Tweets – are built with certain assumptions that may not be true for your product.
"It won't work in the web app "
--Everyone at the FT
But we must be doing something wrong if this is the most popular expression in editorial.
3rd party libraries: solutions?
Well no, not really :(
There isn't really a good solution right now. The fundamental problem is that the web lacks encapsulation.
Recap
Immersive full-screen experience is possible, but very buggy
When going back, restore scroll position and form data
Thoroughly test 3rd party libraries
Allocate time around each iOS launch to fix what Apple break
Naive approach
if
device is online , load from the web
else
load from cache
Browser even has handy method for checking connection: navigator.onLine
navigator.onLine
doesn't work
Flaky connections
Captive portals
Good connection but your server is down or blocked
Fun fact:
On Desktop Firefox navigator.onLine
is only false when File » Work Offline is ticked
Who even uses that anyway?
Live with uncertainty
Assume the device is offline , load from cache
Then try the network for new data in the background
For a page to load offline, you need to use the HTML5 Application Cache
Manifest looks like this:
CACHE MANIFEST
# 2014-05 v1
/lib/fonts/BentonSansBold.ttf
/lib/img/startupscreen/splash-logo.png
NETWORK
*
And to use it you need to add an attribute to <html>
<DOCTYPE html>
<html manifest="mywebapp.manifest">
Sadly, it's not quite that easy
Leaks storage (workarounds exist)
Inflexible and unintuitive API
Makes browser susceptible to scary man in the middle attacks … whether you use it or not
Will soon be replaced by Service Worker
Technically already available in Canary, behind a flag - but you can only install, uninstall and send messages to it right now - all the cool offline stuff not ready yet
Even Chrome dev relations people will tell you the Application Cache is a douchebag.
But…
It does work
across 80%+* of browsers (iOS, Android, Chrome, Opera, IE10+)
Only Chrome and Firefox have committed to building Service Worker implementations
* http://caniuse.com/offline-apps
AppCache OK
Yes it's ugly for developers
But so is almost everything else about building this app
And it enables amazing experiences for users
There are use cases where it works well
Unfortunately, building a newspaper app isn't one of them
A Service Worker:
is a javascript file that you write and can be 'installed' onto your website
lets you to do things with requests between browser and server
has special caches that you can reliably store content in
More code than AppCache - but you can read what it does!
// Install process
this.oninstall = function(event) {
var myCache = new Cache(
'/lib/fonts/BentonSansBold.ttf',
'/lib/img/startupscreen/splash-logo.png'
);
// The coast is only clear when all the resources are ready.
event.waitUntil(myCache.ready());
// Add Cache to the global so it can be used later during onfetch
caches.set("my-cache", myCache);
};
// (scroll for more)
// Request handling
this.onfetch = function(e) {
e.respondWith(caches.match(e.request));
};
Offline news demo app at github.com/serviceworker/offline-news-service-worker
Unlike native apps, offline websites only download updates
when they're open
and only applies those updates on refresh
And some users get stuck on old versions - we're still detecting API calls from Javascript that hasn't been served for a year
App start events split by version of client side code
Guidance
Expect some users to get stuck, add tools to help users recover their apps
Test different versions of client side code against different backend versions
Add monitoring
Add buttons to recovery tools that appear after a CSS animation if JS fails
Version everything - API endpoints, data formats
Client side database migrations are painful. Store data in its simplest form (JSON better than HTML)
Summary
Browsers do much more than render . If you're website doesn't restore state well (scroll, form data) users will be upset
Offline is possible today , with enough persistence
Test , measure and monitor everything, including 3rd parties
I'm not even sure anymore whether this is a good idea