December 21, 2018·Electron
Optimize your Electron app startup time
Optimize the startup time by saving the last rendered state of your app.
Ramón Echeverría
Electron apps can have sub-par performances compared to their native counterparts.
By applying some optimisations to your app you can make the startup feel much faster.
On this app (a music player), 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 dynamic elements in place (track lists, 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 a snapshot of the DOM so we can directly show it when we restart the app?
Turns out it can be done pretty trivially, altough there was a few headaches on the path.
The code
Everything is done from the main process. First a function saves the current HTML state of the app to a file. It contains the current version of the app in its name. If in the future you modify the HTML structure, your users will use the updated version instead of an old cached one.
function 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 this case, I reset some elements to their original state before saving the dom // 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.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 package that into a module if that can be useful to other apps.