PlantUML JS Integration PoC Web Worker

Proof of Concept — Non-blocking rendering using hidden iframes as isolated rendering workers

1 Worker Architecture

Why hidden iframes instead of Web Workers? The PlantUML JS engine (compiled from Java via TeaVM) requires a DOM to render SVG — it uses document.getElementById() and writes directly into DOM elements. Pure Web Workers have no DOM access. Hidden iframes provide the best of both worlds: each iframe has its own DOM and its own JavaScript execution context, so rendering does not block the main thread. This is the same isolation model that GitHub already uses for Mermaid diagrams.

Each ```plantuml block gets its own hidden <iframe> that acts as a dedicated rendering worker. All iframes render in parallel without blocking the main thread or each other.

Main thread

Finds ```plantuml blocks.
Creates one hidden iframe per diagram.
Sends source via postMessage.
Main thread stays free.

postMessage
plantuml source

Hidden iframe (worker)

Loads engine once.
Has its own DOM context.
Renders SVG off-screen.
Sends SVG string back.

postMessage
SVG string

Main thread

Receives SVG string.
Inserts into visible DOM.
Destroys worker iframe.

2 Worker IFrame (rendering side)

plantuml-worker-frame.html
// This HTML page is loaded into each hidden iframe.
// It contains <script> tags for viz-global.js and plantuml.js,
// plus this listener:

plantumlLoad();

const target = document.createElement('div');
target.id = 'out';
document.body.appendChild(target);

window.addEventListener('message', (event) => {
  const { lines, requestId, dark } = event.data;

  const observer = new MutationObserver(() => {
    if (target.querySelector('svg')) {
      observer.disconnect();
      window.parent.postMessage({
        requestId,
        svg: target.innerHTML
      }, '*');
    }
  });
  observer.observe(target, { childList: true, subtree: true });

  window.plantuml.render(lines, 'out', dark ? { dark: true } : undefined);
});

3 Main Thread (dispatcher)

plantuml-worker-dispatcher.js
function renderInWorker(lines, targetId, dark) {
  const requestId = 'puml-' + targetId + '-' + Date.now();

  // Create a hidden iframe as a rendering worker
  const iframe = document.createElement('iframe');
  iframe.src = 'plantuml-worker-frame.html';
  iframe.style.cssText = 'display:none';
  document.body.appendChild(iframe);

  // Listen for the result
  function onMessage(e) {
    if (e.data.requestId !== requestId) return;
    window.removeEventListener('message', onMessage);

    // Insert SVG into the visible target
    document.getElementById(targetId).innerHTML = e.data.svg;

    // Destroy the worker iframe
    iframe.remove();
  }
  window.addEventListener('message', onMessage);

  // Send the render request once the iframe is ready
  iframe.addEventListener('load', () => {
    iframe.contentWindow.postMessage(
      { lines, requestId, dark }, '*'
    );
  });
}

4 What It Looks Like in a README

Parallel rendering: Both diagrams below render simultaneously in separate hidden iframes. The main thread stays responsive throughout — no serialization needed, no render queue.

My Project Architecture

Here's our authentication flow:

plantuml — worker iframe
Rendering in worker…

And here's the deployment topology:

plantuml — worker iframe
Rendering in worker…
0
Server dependencies
0 ms
Main thread blocked
Parallel renders
...
Total render time
Comparison with the basic PoC: The basic version runs the engine directly in the page and must serialize renders (one at a time) because the engine uses shared state. This worker version creates one hidden iframe per diagram, so all diagrams render in parallel without blocking the main thread. The trade-off is higher memory usage (one engine instance per iframe) and a slightly longer startup per diagram (iframe creation + engine loading).