Optimize your Electron app startup time

May 10, 2018

/!\ DRAFT ARTICLE /!\

I haven’t finished redacting this article, but still published it. Please be tender.

It’s well known that Electron apps have sub-par performances vs their native counterparts.

I think that’s true to some extent, altough a greatly coded Electron app can feel as good as a native app to use.

Recently, I applied some optimizations to my app Harmony (a music player) making the startup feel much faster.

Before:

After:

On my app, here is what happens (in short) on startup:

  • HTML & CSS are loaded and rendered
  • Javascript is asynchronously loaded
  • Dependencies are loaded (the longest)
  • JS starts and sets the UI elements in place (tracks, buttons, etc…)

As an end-user, you used to first see the basic HTML structure then after a few seconds element starts to appear where they need to be and fill the UI.

Why not save the state of the app so we can directly show a pre-rendered UI ?

For example, by exporting the DOM before quitting the app and loading it back on start.

Turns out it can be done pretty trivially, altough there is a few headaches on the path.

The code

Everything happens in the main process.

First the function saving the current state of the app to a new HTML file, containing the current version of the app.

Let’s add the current version to the cached file’s name: if in the future you modify the HTML, your users will use the updated version instead of an old cached one.

function saveState() {
	console.log('Saving window for faster startup...')

	let writePath = path.join(app.getPath('userData'), 'harmony'+app.getVersion()+'_index.html').replace(/\\/g, "\\\\") 

	// Cache rendered html for faster startup 🚀
	// regex .replace is for escaping mfucking windows paths
	window.webContents.executeJavaScript(`
		// Reset ui elements
		
		getById('playerBufferBar').style.transform = getById('playerProgressBar').style.transform = 'translateX(0%)'
		
		addClass('playpauseIcon', 'icon-play')
		removeClass('playpauseIcon', 'icon-pause')
		removeClass(".playingIcon", "blink")
		addClass('refreshStatus', 'hide')

		fs.writeFileSync("${writePath}",  '<!DOCTYPE html>'+document.documentElement.outerHTML)

		// Save settings
		store.set('settings', settings)
	
	`)
}

Reset/remove the elements of the UI you want in place and save the HTML to a new file in the userData directory.

Make sure to start by <!DOCTYPE html> in your file or it won’t work when we load it from the Data URL. I spent a while figuring this out.

Now invoke the function from your app’s before-quit event:

app.on('before-quit', () =>  {
	willQuitApp = true
	
	saveState()
})

Also call it from your app’s window’s close event:

window.on('close', (e) => {
		saveState()

		if (willQuitApp || process.platform !== 'darwin') {
			/* the user tried to quit the app */
			window = null
		} else {
			/* the user only tried to close the window */
			e.preventDefault()
			window.hide()
		}
})

Now, if the user has the cached index.html in his cache load it instead of the default index.html:

let cachedIndex = path.join(app.getPath('userData'), `/your_app${app.getVersion()}_index.html`) // Used for faster startup
let indexToPull = fs.existsSync(cachedIndex) ? cachedIndex : path.join(__dirname, '/app/index.html')
	
const pageContent = fs.readFileSync(indexToPull,'utf8')

window.loadURL('data:text/html;charset=UTF-8,' + encodeURIComponent(pageContent), {
	baseURLForDataURL: `file://${__dirname}/app/`
})

Depending on if a cached HTML exists, we load the corresponding file.

We can’t load the cached file from it’s path as we’ll be placed in the userData directory and you won’t be able to access any static files (CSS, JS, images).

So we have to read it and then send it as a Data URL to be rendered.

Now however, your JS will be ‘placed’ in the same directory as your main process file. Say you have in app.js some local dependencies like require('./utils/db') - well it won’t work.

As a simple workaroung hack, I ended up importing my local dependencies like this:

if (__dirname.replace(/\//g, '').endsWith("app")) __dirname = path.join(__dirname, '/app')

const utils = require(path.join(__dirname, '/js/utils.js'))

As app.js will get loaded from the same folder as main.js or from app/settings.html we want to make sure __dirname is always the at the root of where I store my .js files - that’s why we make sure it’s the correct directory and update it if not.

End words

The caveat to this technique is even if the UI appears loaded, you can’t interact with the app until the proper JS is fully loaded.

This can be further optimized by loading first only the UI interaction code then the rest.

For most users it won’t be noticeable tho.