Build Snappier Apps
with React and Web Workers
Who am I?
Mehdi Vasigh
Senior Engineer @
♥️ Making art with code
👩🍳 UI chef
I love meeting new 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*
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? Find me! Or hit me up on...
(RenderATL 2021) Build Snappier Apps with React and Web Workers
By Mehdi Vasigh
(RenderATL 2021) Build Snappier Apps with React and Web Workers
- 64