Build Snappier Apps

with React and Web Workers

JSCamp | July 16th, 2021

Mehdi Vasigh

twitter: @mehdi_vasigh

Who am I?

Mehdi Vasigh

Senior Engineer @ Mailchimp

 

♥️ Creative coding

👩‍🍳 UI chef

I love meeting people!

Twitter: @mehdi_vasigh

What does it mean to have a high-performance web UI?

@mehdi_vasigh

Initial Page Load

  • Time to first byte
  • First meaningful paint
  • Time to interactive
  • Web Vitals & Lighthouse

After Load

  • Smoothness
  • Responsiveness
  • Memory efficiency

@mehdi_vasigh

"Smoothness" means

maintaining a stable frame rate

@mehdi_vasigh

The frame budget

We want to target 60 frames each second*

1000 ms / 60 frames = 16 ms/frame

Browser takes ~4-6 ms per frame to do work

Our JS for each frame should take

~10-12 ms or less

* This is a generalization! The browser optimizes its rendering to avoid unnecessary work.

What happens if we

exceed the frame budget?

@mehdi_vasigh

Browser pixel pipeline

JavaScript

Style

Layout

Paint

Composite

16 ms

@mehdi_vasigh

JavaScript

Style

Layout

Paint

Composite

Browser can skip steps if nothing changes

16 ms

@mehdi_vasigh

JavaScript

Style

Layout

Paint

Composite

16 ms

UI/Local Logic

@mehdi_vasigh

JavaScript

Style

Layout

Paint

Composite

16 ms

CSS-in-JS Runtime

UI/Local Logic

@mehdi_vasigh

JavaScript

Style

Layout

Paint

Composite

16 ms

CSS-in-JS Runtime

UI/Local Logic

Front-end Framework

@mehdi_vasigh

JavaScript

Style

Layout

Paint

Composite

16 ms

CSS-in-JS Runtime

UI/Local Logic

Front-end Framework

State Management Logic

We've exceeded our frame budget and introduced jank

@mehdi_vasigh

JavaScript

Style

Layout

Paint

Composite

16 ms

The more blocking JavaScript that we have, the more frames that we will drop

@mehdi_vasigh

@mehdi_vasigh

Isn't this what React 18 and "concurrent React" does?

@mehdi_vasigh

CPU Core

Task 1

Task 2

Concurrency

CPU Core

Task 1

Parallelism

CPU Core

Task 2

@mehdi_vasigh

JS is single-threaded*

  • One single "execution context" where all code runs
  • If a function takes long to execute, the thread is blocked

@mehdi_vasigh

What does "blocking" mean?

  • Code that occupies the thread
  • Prevents or delays execution of code after it
while (true) {
  console.log('To infinity and beyond!');
}

// Will never run!
extremelyImportantFunction();

@mehdi_vasigh

Web Workers allow us to do work in parallel using separate threads

Web Workers are not the same as Service Workers!

@mehdi_vasigh

@mehdi_vasigh

Web Workers

  • Have their own execution context
  • Don't share memory with main thread or other workers
  • Data can be copied or transferred between contexts

Main Thread

Worker

self.postMessage()
MessageEvent
worker.postMessage()
MessageEvent

@mehdi_vasigh

Browser Support

Source: caniuse.com

Show me the code 👀

// worker.js

function handleMessage(event) {
  if (event.data === 'ping') {
    self.postMessage('pong!');
  }
}

self.addEventListener('message', handleMessage);


// main.js

const worker = new Worker('worker.js');

worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.postMessage('ping'); // "pong!"

"main.js" and "worker.js" are separate files

@mehdi_vasigh

Bundlers make Workers easier

// Vite allows you to import Workers as constructors
// by adding '?worker' to the end of the path!

import PingWorker from './ping.worker?worker';

const worker = new PingWorker();

worker.postMessage('ping');


// Parcel allows you to pass a relative path to a module
// to the Worker constructor

const worker = new Worker('./ping.worker');

worker.postMessage('ping');

A bundler can help you emit separate files for your main bundle and your Worker bundle.

 

Different bundlers and frameworks support Workers in different ways! 

@mehdi_vasigh

Treat Workers like other async code

import { useQuery } from 'react-query';
import MarkdownWorker from './markdown.worker?worker'

const worker = new MarkdownWorker();

// Our Worker renders Markdown to HTML. This function
// wraps the exchange in a Promise (simplified code)
function renderMarkdownToHtml(markdown) {
  return new Promise((resolve) => {
    worker.onmessage = e => {
      const html = e.data;
      resolve(html)
    }
    
    worker.postMessage(markdown);
  })
}

function MarkdownPreview({ markdown }) {
  const { data } = useQuery(['markdown', markdown], () => {
    return renderMarkdownToHtml(markdown)
  });
  
  return <div dangerouslySetInnerHTML={{ __html: data || '' }} />;
}

React Query is great at managing async functions. We can take advantage of that with Worker tasks as well!

Treat Workers like local objects

// worker.js

import * as Comlink from 'comlink';

class MyClass {
  ping() {
    return 'pong!';
  }
}

Comlink.expose(MyClass);

// main.js

import * as Comlink from 'comlink';
import PingWorker from './ping.worker?worker';

const MyClass = Comlink.wrap(new PingWorker());
const instance = await new MyClass();

await instance.ping(); // pong!

Comlink takes away much of the boilerplate code of Web Workers, helping you write code that feels more natural. 

When to use Workers

  • CPU-intensive work
  • Working with a lot of data
  • Image processing
  • Parallelizing tasks
  • Frames are being dropped
  • Games or lots of animations
  • ... anywhere that you have isolated, hot path logic!

@mehdi_vasigh

When not to use Web Workers

  • Work is I/O bound (i.e. network dependent), not CPU bound
  • DOM manipulation is being performed
  • localStorage (or some other unavailable API) is being used
  • Prematurely (if the complexity isn't worth it)

@mehdi_vasigh

Thank you!

Questions? Ask me now or on Twitter!

github: @mvasigh

twitter: @mehdi_vasigh

linkedin: @mehdi-vasigh

(JSCamp 2021) Build Snappier Apps with React and Web Workers

By Mehdi Vasigh

(JSCamp 2021) Build Snappier Apps with React and Web Workers

  • 111