building-studio-in-public:-maintaining-high-performance-in-our-local-development-app-for-enhanced-ux

We’re back with Part 2 of our “Building Studio in Public” series! Today, we’re diving into the challenges we faced while optimizing Studio’s performance. This post will be especially valuable if you’re developing an Electron app and dealing with performance issues or if you’re simply curious about how the Studio app functions behind the scenes.

If you’re following the series, be sure to check out our first post: Using WordPress Components and Tailwind CSS in our Local Development App.

As a reminder, Studio is our free, open source local development app. It’s based on Electron (the focus of today’s post!) and is currently available for Mac and Windows.

Overcoming the challenges of running local development sites

Running a local development site can be complex, often requiring the setup of multiple tools. A typical approach involves using multi-container applications like Docker and Docker Compose in addition to setting up a web server with a WordPress installation and a MySQL database. This process can become even more challenging when managing multiple sites simultaneously.

Studio was designed to simplify this process, allowing users to set up sites quickly without any prior configuration. This capability is powered primarily by the WordPress Playground project, which enables anyone to run a fully functional WordPress site within a browser or Node.js environment. 

For each site created with Studio, we run a basic web server using ExpressJS to handle web requests and use WordPress Playground to process them.

Initially, this was implemented in the Electron-based Studio app without noticeable performance issues.

However, as we expanded our testing across Mac and Windows, we observed some slowness in UI interactions when managing and navigating sites. Everything seemed properly configured, but something was clearly off.

A gif of someone squinting their eyes

Keeping the main process lightweight

As we delved into these performance issues, we discovered that running sites within Electron’s main process was the primary cause of the slowdown. Processing web requests and executing the associated PHP code for WordPress in the main process added extra load, which negatively impacted other operations, aka that UI slowness we were seeing.

screenshots showing the differences of starting a site and creating a site in Electron's main process vs the dedicated process

Electron’s documentation is incredibly valuable for addressing performance issues, particularly those related to blocking the main process. It was clear that maintaining a lightweight main process is crucial, and avoiding heavy or blocking operations in this context is essential. However, this realization presented a new challenge: how do we detach the running sites from the main process?

Spawning dedicated processes

To tackle the performance issues, we adopted the tried-and-true strategy of “divide and conquer.” 

The idea was to run Studio’s sites in dedicated processes, separate from the main one. Since Electron is built on Node.js, spawning child processes seemed like a plausible solution. However, Electron also offers a utilityProcess utility, which behaves similarly to Node’s child processes, but operating at the browser level and aligning more closely with Electron’s app model.

While this approach promised to alleviate the load on the main process, it also introduced additional complexity. We had to manage these new processes and handle communication between the main and dedicated processes via messages. Additionally, we encountered challenges related to the build configuration and using Webpack for building the application.

Below is a full example of implementing this approach (click to expand each example to see the full code):

Dedicated Process Manager (process.js):
const { app, utilityProcess } = require( 'electron' );

// This path should be calculated dynamically as the file could be in
// different locations depending on the build configuration
const PROCESS_MODULE_PATH = './process-child.js';

const DEFAULT_RESPONSE_TIMEOUT = 120000;

class Process {
	lastMessageId = 0;
	process;
	ongoingMessages = {};

	async init() {
		return new Promise( ( resolve, reject ) => {
			const spawnListener = async () => {
				// Removing exit listener as we only need it upon starting
				this.process?.off( 'exit', exitListener );
				resolve();
			};
			const exitListener = ( code ) => {
				if ( code !== 0 ) {
					reject( new Error( `process exited with code ${ code } upon starting` ) );
				}
			};

			this.process = utilityProcess
				.fork( PROCESS_MODULE_PATH, [], {
					serviceName: 'dedicated-process',
					env: {
						...process.env,
						IN_CHILD_PROCESS: 'true',
						APP_NAME: app.name,
						// Note that Electron context won't be available in the dedicated process.
						// Add here other environment variables that might be needed.
					},
				} )
				.on( 'spawn', spawnListener )
				.on( 'exit', exitListener );
		} );
	}

	// This is an example function. Feel free to add more for other purposes.
	async exampleFunc( command, args ) {
		const message = 'exampleFunc';
		const messageId = this.sendMessage( message, { command, args } );
		return await this.waitForResponse( message, messageId );
	}

	// It's important to keep in mind that the process will be running
	// until it's explicitly stopped.
	async stop() {
		await this.killProcess();
	}

	sendMessage( message, data ) {
		const process = this.process;
		if ( ! process ) {
			throw Error( 'The process is not running' );
		}

		const messageId = this.lastMessageId++;
		process.postMessage( { message, messageId, data } );
		return messageId;
	}

	async waitForResponse( originalMessage, originalMessageId, timeout = DEFAULT_RESPONSE_TIMEOUT ) {
		const process = this.process;
		if ( ! process ) {
			throw Error( 'The process is not running' );
		}
		if ( this.ongoingMessages[ originalMessageId ] ) {
			throw Error(
				`The 'waitForResponse' function was already called for message ID ${ originalMessageId } from the message '${ originalMessage }'. 'waitForResponse' may only be called once per message ID.`
			);
		}

		return new Promise( ( resolve, reject ) => {
			const handler = ( { message, messageId, data, error } ) => {
				if ( message !== originalMessage || messageId !== originalMessageId ) {
					return;
				}
				process.removeListener( 'message', handler );
				clearTimeout( timeoutId );
				delete this.ongoingMessages[ originalMessageId ];
				if ( typeof error !== 'undefined' ) {
					console.error( error );
					reject( new Error( error ) );
					return;
				}
				resolve( data );
			};

			const timeoutHandler = () => {
				reject( new Error( `Request for message ${ originalMessage } timed out` ) );
				process.removeListener( 'message', handler );
			};
			const timeoutId = setTimeout( timeoutHandler, timeout );
			const cancelHandler = () => {
				clearTimeout( timeoutId );
				reject( {
					error: new Error( `Request for message ${ originalMessage } was canceled` ),
					canceled: true,
				} );
				process.removeListener( 'message', handler );
			};
			this.ongoingMessages[ originalMessageId ] = { cancelHandler };

			process.addListener( 'message', handler );
		} );
	}

	async killProcess() {
		const process = this.process;
		if ( ! process ) {
			throw Error( 'The process is not running' );
		}

		this.cancelOngoingMessages();

		return new Promise( ( resolve, reject ) => {
			process.once( 'exit', ( code ) => {
				if ( code !== 0 ) {
					reject( new Error( `Process exited with code ${ code } upon stopping` ) );
					return;
				}
				resolve();
			} );
			process.kill();
		} ).catch( ( error ) => {
			console.error( error );
		} );
	}

	cancelOngoingMessages() {
		Object.values( this.ongoingMessages ).forEach( ( { cancelHandler } ) => {
			cancelHandler();
		} );
	}
}

module.exports = Process;

Dedicated Process Logic (process-child.js):
// Replace with initial setup logic based on the environment variables if needed.
console.log( `Run initial setup for app: ${ process.env.APP_NAME }` );

const handlers = {
	exampleFunc: createHandler( exampleFunc ),
};

async function exampleFunc( data ) {
	const { command, args } = data;
	// Replace this with the desired logic.
	console.log( `Run heavy operation ${ command } with args: ${ args }` );
}

function createHandler( handler ) {
	return async ( message, messageId, data ) => {
		try {
			const response = await handler( data );
			process.parentPort.postMessage( {
				message,
				messageId,
				data: response,
			} );
		} catch ( error ) {
			process.parentPort.postMessage( {
				message,
				messageId,
				error: error?.message || 'Unknown Error',
			} );
		}
	};
}

process.parentPort.on( 'message', async ( { data: messagePayload } ) => {
	const { message, messageId, data } = messagePayload;
	const handler = handlers[ message ];
	if ( ! handler ) {
		process.parentPort.postMessage( {
			message,
			messageId,
			error: Error( `No handler defined for message '${ message }'` ),
		} );
		return;
	}
	await handler( message, messageId, data );
} );
Run example (main.js):
async function runExample() {
	const process = new Process();
	await process.init();
	await process.exampleFunc( 'my-command', [ 'example', 100 ] );
}

…
app.whenReady().then( () => {
	runExample();
} );
…

Note: The code above has been adapted for use in a generic example Electron project. You can test it using Electron Fiddle.

Build configuration and Webpack

Our project build setup relies on Forge and Webpack. Implementing dedicated processes introduced extra complexity, as we initially bundled all the code into a single file. 

However, since dedicated processes require their code to run in isolation from the main process, we needed to separate the bundles. After adjusting the Webpack configuration, we successfully set it up to produce the necessary files.

Below is an example of the changes we applied (click to expand each example to see the full code):

Before:
import { type Configuration } from 'webpack';

export const mainConfig: Configuration = {
	// This is the main entry point for your application, it's the first file
	// that runs in the main process.
	entry: './src/index.ts',

...
After:
import path from 'path';
import { type Configuration, DefinePlugin } from 'webpack';

// Extra entries are bundled separately from the main bundle. They are primarily used
// for worker threads and forked processes, which need to be loaded independently.
const extraEntries = [
	{
		name: 'siteServerProcess',
		path: './src/lib/site-server-process-child.ts',
		exportName: 'SITE_SERVER_PROCESS_MODULE_PATH',
	},
	// Here you can configure other dedicated processes
];

export default function mainConfig( _env: unknown, args: Record< string, unknown > ) {
	const isProduction = args.mode === 'production';

	// Generates the necessary plugins to expose the module path of extra entries.
	const definePlugins = extraEntries.map( ( entry ) => {
		// The path calculation is based on how the Forge's webpack plugin generates the path for Electron files.
		// Reference: https://github.com/electron/forge/blob/b298b2967bdc79bdc4e09681ea1ccc46a371635a/packages/plugin/webpack/src/WebpackConfig.ts#L113-L140
		const modulePath = isProduction
			? `require('path').resolve(__dirname, '..', 'main', '${ entry.name }.js')`
			: JSON.stringify( path.resolve( __dirname, `.webpack/main/${ entry.name }.js` ) );
		return new DefinePlugin( {
			[ entry.exportName ]: modulePath,
		} );
	} );

	return {
		...mainBaseConfig,
		plugins: [ ...( mainBaseConfig.plugins || [] ), ...definePlugins ],
	};
}

export const mainBaseConfig: Configuration = {
	entry: {
		// This is the main entry point for your application, it's the first file
		// that runs in the main process.
		index: './src/index.ts',
		// Inject extra entries into the Webpack configuration.
		// These entries are primarily used for worker threads and forked processes.
		...extraEntries.reduce( ( accum, entry ) => {
			return { ...accum, [ entry.name ]: entry.path };
		}, {} ),
	},

...

Note: The code above is directly from Studio, written in TypeScript.

Bonus tip: avoid blocking file system operations

We also noticed performance issues when using synchronous file system operations when building Studio, specifically when using the synchronous versions of functions, which can block the main process. To prevent this, it’s best to use the promise-based or callback versions of these functions.

For example, instead of using:

fs.readFileSync( path, 'utf8' );

Use:

await fs.readFile( path, 'utf8' );

Ready to build?

If this information has piqued your interest, or if you’re developing WordPress sites, start leveraging the power of Studio today. It’s free, it’s open source, and it seamlessly integrates into your development workflow.

After downloading Studio, connect it to your WordPress.com account (free or paid) to unlock features like Demo Sites.

Want to contribute to Studio? Here are some GitHub issues you can dive into:

Similar Posts