DEV Community

Chynna He
Chynna He

Posted on

Building a Non-Blocking JPG-to-SVG Converter in the Browser with WebAssembly

Converting a raster image to SVG sounds like a backend job: upload the file, enqueue a worker, run a native tracer, and return the result.

For simple logos, icons, badges, and flat illustrations, that architecture is often unnecessary. Modern browsers can decode the image, run a WebAssembly tracer, construct the SVG, and let the user download it without sending the source image to a server.

I recently used this approach for a browser-based JPG-to-SVG workflow. This post focuses on the engineering decisions that made the converter responsive and predictable rather than on the surrounding UI.

The pipeline

The browser-side pipeline has seven stages:

  1. Decode the source image.
  2. Downscale it to a bounded tracing resolution.
  3. Draw the pixels into a temporary canvas.
  4. Initialize a WebAssembly vector tracer.
  5. Run the tracer in short time slices.
  6. Validate and serialize the generated SVG.
  7. Remove every temporary DOM node and object URL.

The important part is not any single API. It is keeping CPU work, memory, and cleanup under control.

Lazy-load the WebAssembly module

A vector tracer is much larger than normal UI code, so it should not be part of the initial page bundle. Load it only when a conversion begins and cache the promise so concurrent calls share the same module initialization:

type VTracerModule = typeof import("vtracer-webapp");

let vtracerPromise: Promise<VTracerModule> | null = null;

function loadVTracer() {
  vtracerPromise ??= import("vtracer-webapp");
  return vtracerPromise;
}
Enter fullscreen mode Exit fullscreen mode

This pattern has two useful properties:

  • The landing page stays lightweight for visitors who never upload an image.
  • A second conversion reuses the initialized module instead of downloading and compiling it again.

Start image decoding and module loading in parallel:

const [{ ColorImageConverter }, image] = await Promise.all([
  loadVTracer(),
  loadImage(sourceUrl),
]);
Enter fullscreen mode Exit fullscreen mode

On a cold run, this hides some of the WebAssembly startup cost behind image decoding.

Bound the working resolution

Tracing cost grows with the number of pixels and the complexity of the image. Passing a 6000 × 4000 photograph directly into a tracer is a good way to freeze a tab and generate an SVG with thousands of noisy paths.

Instead, keep the original dimensions for the final SVG metadata but trace a smaller working image:

const maxSide = detail === "high" ? 900 : 560;
const scale = Math.min(
  1,
  maxSide / Math.max(image.naturalWidth, image.naturalHeight)
);

const width = Math.max(1, Math.round(image.naturalWidth * scale));
const height = Math.max(1, Math.round(image.naturalHeight * scale));
Enter fullscreen mode Exit fullscreen mode

This is a product decision as much as a performance optimization. A JPG-to-SVG converter is best for flat artwork with clear edges, not for reproducing every pixel of a photograph. A bounded tracing resolution reinforces that goal.

After tracing, the SVG can retain the original width and height while using a viewBox based on the working coordinate system.

Use an isolated canvas and SVG workspace

The tracer I used expects IDs for a canvas input and an SVG output element. Rather than coupling those elements to the visible React tree, create a hidden workspace for each conversion:

function makeWorkspace(width: number, height: number) {
  const wrapper = document.createElement("div");
  wrapper.hidden = true;

  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  canvas.id = crypto.randomUUID();

  const svg = document.createElementNS(
    "http://www.w3.org/2000/svg",
    "svg"
  );
  svg.id = crypto.randomUUID();
  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);

  wrapper.append(canvas, svg);
  document.body.append(wrapper);

  return {
    canvas,
    svg,
    dispose: () => wrapper.remove(),
  };
}
Enter fullscreen mode Exit fullscreen mode

The visible interface only needs progress state and the final serialized SVG. The temporary workspace can be destroyed in a finally block even if decoding or tracing fails.

Keep the main thread responsive

WebAssembly is fast, but a long synchronous loop still blocks input, rendering, and accessibility updates. The tracer exposes a tick() method, which makes cooperative scheduling possible.

Run ticks for a small time budget, yield to the browser, and continue later:

function runConverter(converter: {
  init(): void;
  tick(): boolean;
  free(): void;
}) {
  converter.init();

  return new Promise<void>((resolve, reject) => {
    const step = () => {
      try {
        let done = false;
        const startedAt = performance.now();

        while (!done && performance.now() - startedAt < 25) {
          done = converter.tick();
        }

        if (done) {
          converter.free();
          resolve();
          return;
        }

        window.setTimeout(step, 1);
      } catch (error) {
        converter.free();
        reject(error);
      }
    };

    window.setTimeout(step, 1);
  });
}
Enter fullscreen mode Exit fullscreen mode

A 25 ms slice is not a universal constant. It is a practical starting point. Shorter slices improve responsiveness but add scheduling overhead; longer slices finish sooner but produce more noticeable jank. Measure on mid-range hardware, not only on a development machine.

A Web Worker is another option, but it is not always the first one to reach for. If the library requires DOM element IDs, moving the entire pipeline off the main thread may require OffscreenCanvas, message serialization, or changes inside the library. Cooperative ticks provide a useful middle ground.

Map simple controls to tracing parameters

Users understand controls such as color count and detail level. They should not have to understand spline thresholds or clustering modes.

Translate a small UI surface into a stable parameter set:

const highDetail = detail === "high";
const colorPrecision = colors >= 12 ? 1 : colors >= 8 ? 2 : 3;

const params = {
  mode: "spline",
  clustering_mode: "color",
  hierarchical: "stacked",
  corner_threshold: degreesToRadians(highDetail ? 50 : 60),
  length_threshold: highDetail ? 3.5 : 4.5,
  filter_speckle: highDetail ? 8 : 16,
  color_precision: colorPrecision,
  path_precision: highDetail ? 3 : 2,
};
Enter fullscreen mode Exit fullscreen mode

There is a real tradeoff here:

  • More colors preserve subtle differences but create more layers and paths.
  • Higher path precision can improve curves but increases output size.
  • More aggressive speckle filtering removes JPG artifacts but may erase small intentional details.

For logos and flat graphics, the smallest palette that preserves the design is usually the best default.

Validate before offering a download

A completed trace does not automatically mean a useful result. Validate that the SVG contains at least one path and compute a lightweight summary for the UI:

const paths = Array.from(svg.querySelectorAll("path"));

if (paths.length === 0) {
  throw new Error("No vector paths were generated.");
}

const fills = new Set(
  paths
    .map((path) => path.getAttribute("fill"))
    .filter(Boolean)
);

const serialized = new XMLSerializer().serializeToString(svg);
Enter fullscreen mode Exit fullscreen mode

The path count and color count are useful signals. A surprisingly large path count often means the JPG contains compression noise, shadows, or photographic texture. The interface can recommend lowering the color count or cleaning the source image before another run.

For the download, create a Blob instead of navigating to a massive data URL:

const blob = new Blob([serialized], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);

const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "converted.svg";
anchor.click();

URL.revokeObjectURL(url);
Enter fullscreen mode Exit fullscreen mode

Cleanup is part of correctness

Image conversion creates temporary resources quickly:

  • hidden canvas and SVG nodes
  • object URLs for uploads and downloads
  • a WebAssembly converter instance
  • patched event handlers or logging hooks
  • serialized SVG strings held in component state

Treat every one as an owned resource. Remove DOM workspaces in finally, revoke object URLs when previews change, and call the converter's free() method on both success and failure.

Without explicit cleanup, repeated conversions can make a single-page app slower even though each individual conversion appears to work.

What this architecture does not solve

Automatic tracing is not the same as a designer redrawing an asset. Photos, hair, foliage, gradients, and soft shadows can produce huge SVGs that are technically valid but difficult to edit.

The best inputs are:

  • high-contrast logos
  • icons and interface symbols
  • badges and stickers
  • line art
  • flat illustrations with limited colors

For noisy JPG files, preprocess first: crop irrelevant background, use the highest-resolution source available, reduce colors, and remove obvious compression artifacts.

Takeaway

A practical browser vectorizer is mostly about boundaries:

  • bound the input resolution
  • lazy-load expensive code
  • slice CPU work so the UI can breathe
  • expose a small set of understandable controls
  • validate the generated document
  • clean up every temporary resource

WebAssembly makes local JPG-to-SVG conversion possible. Careful scheduling and lifecycle management are what make it feel like a real web tool rather than a demo.

Top comments (0)