Skip to content

Splitting Work: Multi-Threaded Programming in Deno

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Deno is a new runtime for JavaScript/TypeScript built on top of the V8 JavaScript engine. It was created as an alternative for Node.js with a focus on security and modern language features.

Here at This Dot, we've been working with Deno for a while, and we've even created a starter kit that you can use to scaffold your next backend Deno project. The starter kit uses many standard Deno modules, such as the Oak web server and the DenoDB ORM. One issue you may encounter, when scaling an application from this starter kit, is how to handle expensive or long-running asynchronous tasks and operations in Deno without blocking your server from handling more requests.

Deno, just like Node.js, uses an event loop in order to process asynchronous tasks. This event loop is responsible for managing the flow of Deno applications and handling the execution of asynchronous tasks. The event loop is executed in a single thread. Therefore, if there is some CPU-intensive or long-running logic that needs to be executed, it needs to be offloaded from the main thread.

This is where Deno workers come into play. Deno workers are built upon the Web Worker API specification, and provide a way to run JavaScript or TypeScript code in separate threads, allowing you to execute CPU-intensive or long-running tasks concurrently, without blocking the event loop. They communicate with the main process through a message-passing API.

In this blog post, we will show you how to expand on our starter kit using Deno Workers. In our starter kit API, where we have CRUD operations for managing technologies, we'll modify the create endpoint to also read an image representing the technology and generate thumbnails for that image.

Generating thumbnails

Image processing is CPU-intensive. If the image being processed is large, it may require a significant amount of CPU resources to complete in a timely manner. When including image processing as part of an API, it's definitely a good idea to offload that processing to a separate thread if you want to keep your API responsive.

Although there are many image processing libraries out there for the Node ecosystem, the Deno ecosystem does not have as many for now. Fortunately, for our use case, using a simple library like deno-image is good enough. With only a few lines of code, you can resize any image, as shown in the below example from deno-image's repository:

import { resize } from "https://deno.land/x/deno_image/mod.ts";

const img = await resize(Deno.readFileSync("./demo/img.jpg"), {
  width: 100,
  height: 100,
});

Deno.writeFileSync("./demo/result.jpg", img);

Let's now create our own thumbnail generator. Create a new file called generate_thumbnails.ts in the src/worker folder of the starter kit:

// src/worker/generate_thumbnails.ts
import { resize } from 'https://deno.land/x/deno_image@v0.0.2/index.ts';
import { createHash } from 'https://deno.land/std@0.104.0/hash/mod.ts';

export async function generateThumbnails(imageUrl: string): Promise<void> {
	const imageResponse = await fetch(imageUrl);
	const imageBufferArray = new Uint8Array(await imageResponse.arrayBuffer())

	for (const size of [75, 100, 125, 150, 200]) {
		const img = await resize(imageBufferArray, {
			width: size,
			height: size,
		});

		const imageUrlHash = createHash("sha1").update(imageUrl).toString();

		Deno.writeFileSync(`./public/images/${imageUrlHash}-${size}x${size}.png`, img);
	}
}

The function uses fetch to retrieve the image from a remote URL, and store it in a local buffer. Afterwards, it goes through a predefined list of thumbnail sizes, calling resize() for each, and then saving each image to the public/images folder, which is a public folder of the web server. Each image's filename is generated from the original image's URL, and appended with the thumbnail dimensions.

Calling the web worker

The web worker itself is a simple Deno module which defines an event handler for incoming messages from the main thread. Create worker.ts in the src/worker folder:

// src/worker/worker.ts
import { generateThumbnails } from './generate_thumbnails.ts';

self.onmessage = async (event) => {
	console.log('Image processing worker received a new message', event.data)
  const { imageUrl } = event.data;
	await generateThumbnails(imageUrl);
	self.close();
}

The event's data property expects an object representing a message from the main thread. In our case, we only need an image URL to process an image, so event.data.imageUrl will contain the image URL to process. Then, we call the generateThumbnails function on that URL, and then we close the worker when done.

Now, before calling the web worker to resize our image, let's modify the Technology type from the GraphQL schema in the starter kit to accept an image URL. This way, when we execute the mutation to create a new technology, we can execute the logic to read the image, and resize it in the web worker.

// src/graphql/schema/technology.ts

import { gql } from '../../../deps.ts';

export const technologyTypes = gql`
  type Technology {
    id: String!
    displayName: String!
    description: String!
    url: String!
    createdAt: String!
    updatedAt: String!
		imageUrl: String!!
  }

  ...
`

After calling deno task generate-type-definition to generate new TypeScript files from the modified schema, we can now use the imageUrl field in our mutation handler, which creates a new instance of the technology.

At the top of the mutation_handler.ts module, let's define our worker:

// src/graphql/resolvers/mutation_handler.ts
import { GraphqlContext } from '../interfaces/graphql_interfaces.ts';
// other imports

const thumbnailWorker = new Worker(new URL("../../worker/worker.ts", import.meta.url).href, { type: "module" });

This is only done once, so that Deno loads the worker on module initialization. Afterwards, we can send messages to our worker on every call of the mutation handler using postMessage:

// src/graphql/resolvers/mutation_handler.ts

export const createTechnology = async (
	_parent: unknown,
	{ input }: MutationCreateTechnologyArgs,
	{ cache }: GraphqlContext,
): Promise<Technology> => {
	await cache.invalidateItem('getTechnologies');
	const technologyModel = await TechnologyRepository.create({
		...input,
	});

	// Generate thumbnails asynchronously in a separate thread
	thumbnailWorker.postMessage({imageUrl: input.imageUrl})

	return {
		id: technologyModel.id,
		displayName: technologyModel.displayName,
		description: technologyModel.description,
		url: technologyModel.url,
		createdAt: technologyModel.createdAt,
		updatedAt: technologyModel.updatedAt,
	} as Technology;
};

With this implementation, your API will remain responsive, because post-processing actions such as thumbnail generation are offloaded to a separate worker. The main thread and the worker thread communicate with a simple messaging system.

CleanShot 2022-12-16 at 12.23.14@2x

Conclusion

Overall, Deno is a powerful and efficient runtime for building server-side applications, small and large. Its combination of performance and ease-of-use make it an appealing choice for developers looking to build scalable and reliable systems. With its support for the Web Worker API spec, Deno is also well-suited for performing large-scale data processing tasks, as we've shown in this blog post.

If you want to learn more about Deno, check out deno.framework.dev for a curated list of libraries and resources. If you are looking to start a new Deno project, check out our Deno starter kit resources at starter.dev.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co