Real-Time Streaming Rendering Optimization - Improving EVA Architecture Based on Canvas, Web Worker, OffscreenCanvas
Hello, I’m Junhyung Yoo, a frontend engineer on the EVA team.
One of the core features of the EVA service is real-time streaming, which allows users to monitor video feeds from dozens of cameras simultaneously. As usage expanded from brief checks to long-term on-site monitoring, unexpected performance bottlenecks began to surface.
"When I leave the screen on for a long time, the browser gradually slows down and eventually the tab crashes."
To address this issue, we’d like to share our journey of improving the rendering architecture using Canvas, Web Worker, and OffscreenCanvas.
1. Background: Why Did Problems Appear Over Time?
In its early days, EVA adopted a very common streaming approach using the <img> tag combined with Blob (Object URL).
Previous Approach (Blob-Based Rendering)
- MJPEG stream data is received from the server in Blob form.
- A temporary URL is generated using
URL.createObjectURL(blob). - The URL is assigned to the
srcof an<img>tag, allowing the browser to render the image.
While this implementation was simple, two critical issues emerged in the specialized environment of long-term monitoring.
- Memory Overhead: A unique URL string is generated for every frame (around 30 frames per second). Even when calling
revokeObjectURL, delays in the browser’s internal image cache and garbage collection (GC) caused memory usage to continuously increase, eventually leading to Out of Memory (OOM) errors. - Main Thread Blocking: Image decoding occurs on the main (UI) thread. When processing high-resolution video, the event loop is delayed, resulting in UI lag such as slow clicks or scrolling—commonly known as jank.
2. Network Tab Analysis: Understanding MJPEG
The first step in improving performance was analyzing the network layer. MJPEG streaming behaves differently from typical HTTP requests.
multipart/x-mixed-replace
MJPEG uses the Content-Type: multipart/x-mixed-replace; boundary=... header, which allows the server to continuously push image frames over a single HTTP connection.
- Network Tab Characteristics: The request never completes and remains in a 'Pending' state. The browser keeps the connection open and continuously receives binary data.
- Binary Data Structure: Each frame consists of JPEG binary data (
0xFF 0xD8 ... 0xFF 0xD9) separated by a specificboundarystring.
Because the previous approach converted this massive stream of binary data into Blobs and parsed it on the main thread, browser load increased exponentially as more data accumulated.
3. First Optimization: Canvas and createImageBitmap
To move away from memory management that relied heavily on the browser’s garbage collector, we introduced the Canvas API and adopted an explicit memory management approach.
Asynchronous Bitmap Rendering
The createImageBitmap API allows images to be decoded asynchronously in the background before being rendered to the screen.
// @src/entities/devices/components/stream/MJPEGStream.tsx
// Immediately release memory after drawing the bitmap on the canvas
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, 0);
bitmap.close(); // Explicitly release memory
The key point of this approach is bitmap.close(). By explicitly destroying bitmap resources after use, we were able to keep memory usage stable. In addition, by eliminating reflow caused by changing the src of an <img> tag and switching to GPU-accelerated canvas drawing, overall rendering efficiency was significantly improved.
4. Second Optimization: Separating Computation with Web Workers
While rendering became lighter, the task of receiving stream data and extracting JPEG frames from binary data (boundary parsing) was still handled by the main thread. Performing real-time string searches on millions of bytes per second places a heavy burden on the CPU.
To solve this, we introduced Web Workers and applied a clear division of responsibilities:
"Data processing in the background, rendering on the main thread."
Optimizing Data Transfer (Transferable Objects)
When sending large images from a worker to the main thread, copying data results in severe performance degradation. We leveraged Transferable Objects to transfer ownership of memory without copying, enabling a zero-copy data flow.
5. Final Optimization: Introducing OffscreenCanvas
Despite these improvements, the final drawing step still occurred on the main thread. The final piece of the puzzle was OffscreenCanvas, which allows control of the canvas itself to be transferred to a worker.

Even when the main thread is blocked (left), image processing running in the worker continues to update in real time without interruption. (Source: Kakao Tech Blog)
Toward 0% Rendering Load on the Main Thread
After transferring control using transferControlToOffscreen(), rendering is performed entirely inside the worker.
// @src/entities/devices/components/stream/mjpeg.worker.ts
const bitmap = await createImageBitmap(blob);
if (ctx && canvas) {
// Worker directly draws on the canvas (0% main-thread interference)
ctx.drawImage(bitmap, 0, 0);
if (config.showArea && config.area) {
drawPolygonArea(ctx, config.area); // Area overlay logic also runs in the worker
}
}
bitmap.close();
With this architecture, no matter how heavy the workload on the main thread becomes, streaming video continues to play smoothly and independently on a separate thread.
🌐 Browser Compatibility and Automatic Fallback
While OffscreenCanvas offers powerful capabilities, browser support varies. In the EVA service, browser features are automatically detected and conditionally handled based on the user’s environment.
| Browser | Supported Version | Notes |
|---|---|---|
| Chrome | 69+ | Primary support |
| Edge | 79+ | Supported from Chromium-based versions |
| Firefox | 105+ | Enabled by default starting from v105 |
| Safari | 16.4+ | Latest macOS/iOS recommended |
| Opera | 56+ | - |
EVA’s Adaptive Rendering Strategy:
- Modern browsers: Enable
OffscreenCanvasto keep main-thread load at 0%. - Older browsers (e.g., Safari 15 or below): Detect feature availability and automatically fall back to the first optimization approach—main-thread Canvas rendering.
This ensures a seamless streaming experience across all browser environments.
6. Additional Optimizations: Buffer Reuse and Faster Parsing
Performance is determined by details. We applied several additional optimizations within the worker logic.
- Fixed Buffer Reuse: Instead of creating new
Uint8Arrayinstances each time, we reused fixed-size buffers and managed data usingcopyWithin. This significantly reduced the frequency of garbage collection (GC). - High-Speed Parsing with
indexOf: Rather than using simple loops to find matching bytes in binary data, we leveraged the built-inindexOfmethod to skip unnecessary byte comparisons. Even this simple optimization dramatically reduced frame drops.
7. Conclusion: A More Robust EVA Monitoring Environment
Through this optimization effort, the EVA service achieved the following results:
- Memory Stability: Memory usage remains stable even during long-term operation, eliminating OOM errors.
- UI Responsiveness: UI interactions such as menu navigation and button clicks remain smooth—even during high-resolution streaming—at a near-native app level.
- Stable Frame Rates: By separating threads, consistent frame rates are maintained regardless of network latency or main-thread load.
This project reinforced a key principle of frontend performance optimization:
"How free you keep the browser’s main thread makes all the difference."
Thank you for reading!
Technology Summary
- Web Workers API: Execute computations on background threads
- OffscreenCanvas: Rendering independent of the main thread
- createImageBitmap: Asynchronous image decoding with explicit memory management
- Transferable Objects: High-speed data transfer without copy overhead












