It's well known that Electron apps usually have sub-par performances compared to their native counterparts. Although a greatly coded Electron app can feel as smooth 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: [insert gif]

After:  [insert gif]

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 was a few headaches on the path.

The code

Everything here is executed from the main process.

First a function saves the current state of the app to an HTML file.

It contains the current version of the app in its name. If in the future you modify the HTML, your users will use the updated version instead of an old cached one.

const saveState = () => {
	console.log('Saving window for faster startup...')

	// regex .replace is for escaping mfucking windows paths
	let writePath = path.join(app.getPath('userData'), 'harmony'+app.getVersion()+'_index.html').replace(/\\/g, "\\\\") 

	// Cache rendered html for faster startup 🚀
	
	window.webContents.executeJavaScript(`
		
		// This part depends on your app
		// In my case, I reset some elements to their original page before saving the page

		// 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')

		// Here we write the DOM to the 'userData' folder
		// so we can use this for the next startup

		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 your saved HTML begins with <!DOCTYPE html>  or it won't work when we loading from the Data URL. Spent a while on that one :P

Now we invoke the function on before-quit, every time we close the app.

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

Also call it from your app 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 will use 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')

// We read the file content 
// so the Node context isn't in the userData folder
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 its path as we'll be placed in the userData directory and 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 context 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. You're relative to your main folder.

require('./app/js/utils/db')

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 its not noticeable tho.

Maybe i'll try to build that into a module if that can be useful to other apps.