The SVG Serialization Trap: Why createElementNS Broke Our Thumbnail Pipeline

The Setup

We have a web app where users create SVG artwork through code. Each workspace compiles to an SVG path, rendered live in the browser. Thumbnails are generated client-side: clone the SVG, rasterize it to PNG via Image + createImageBitmap, then upload.

This pipeline worked perfectly for user-initiated thumbnails. But when we built an admin backfill tool to generate thumbnails in bulk for workspaces that didn't have them yet, every single thumbnail came back blank — just the background color, no path content.

Three Paths, One Broken

Our app had three ways to generate a thumbnail, all feeding into the same generateThumbnail function:

  1. Auto-save (user edits, goes idle): clones the live SVG from the preview pane
  2. Manual crop (user picks a crop region): clones the same live SVG
  3. Admin backfill (new): compiles the workspace code, builds an SVG programmatically, passes it in

Paths 1 and 2 worked. Path 3 produced blank images. The shared rasterization pipeline was identical — so the difference had to be in how the SVG element was created.

The Working Path

The live preview SVG is created by the browser's HTML parser from a template:

<svg id="preview" xmlns="http://www.w3.org/2000/svg">
  <rect id="preview-bg" width="100%" height="100%"></rect>
  <path id="preview-path" fill="none"></path>
</svg>

Attributes are set dynamically via setAttribute(). When it's time to generate a thumbnail, the code clones this element, adjusts the viewBox for cropping, serializes it, and loads the result as an image:

const clone = svgElement.cloneNode(true);
clone.setAttribute('viewBox', `${cropX} ${cropY} ${cropSize} ${cropSize}`);
clone.setAttribute('width', String(rasterSize));
clone.setAttribute('height', String(rasterSize));

const svgString = new XMLSerializer().serializeToString(clone);
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);

const img = new Image();
img.src = url;
// ... wait for load, createImageBitmap, draw to canvas

This works every time.

The Broken Path

The admin backfill doesn't have a live preview — it fetches workspace data from an API, compiles the code to get path data, and constructs an SVG element programmatically:

_createTempSvg(state) {
  const ns = 'http://www.w3.org/2000/svg';
  const svg = document.createElementNS(ns, 'svg');
  svg.setAttribute('xmlns', ns);
  svg.setAttribute('width', state.width);
  svg.setAttribute('height', state.height);
  svg.setAttribute('viewBox', `0 0 ${state.width} ${state.height}`);

  const bg = document.createElementNS(ns, 'rect');
  bg.setAttribute('width', state.width);
  bg.setAttribute('height', state.height);
  bg.setAttribute('fill', state.background);
  svg.appendChild(bg);

  const path = document.createElementNS(ns, 'path');
  path.setAttribute('d', state.pathData);
  path.setAttribute('stroke', state.stroke);
  path.setAttribute('stroke-width', state.strokeWidth);
  path.setAttribute('fill', state.fillEnabled ? state.fill : 'none');
  svg.appendChild(path);

  return svg;
}

This element was then passed through the exact same clone → serialize → Image → bitmap pipeline. Same function, same code path. But the thumbnails were blank.

What We Saw

The generated thumbnails weren't completely empty. The background rectangle rendered correctly — a light gray #f5f5f5 fill covering the full viewport. But the <path> element, despite having valid d attribute data (verified via console logging), rendered nothing.

This told us:

  • The SVG loaded successfully as an image (background appeared)
  • The <path> element was present in the serialized markup
  • Something about the serialized output was causing the browser's SVG-as-image renderer to silently ignore the path

The Investigation

We verified that result.path from the compiler contained valid, non-empty SVG path data. We verified the viewBox, width, and height were set correctly. We checked for namespace issues, tried stripping inline CSS styles from the element, and even added a wait-and-retry mechanism in case of timing conflicts.

None of it helped.

The frustrating part was that both SVGs — the one from the HTML parser and the one from createElementNS — should have been structurally identical after serialization. An SVG is an SVG, right?

The Root Cause

When you create SVG elements with document.createElementNS() in an HTML document, you're creating DOM nodes that live in the SVG namespace but exist within the HTML DOM. When XMLSerializer.serializeToString() serializes these nodes back to XML, it can produce subtly different output than what you'd get from a browser-parsed SVG.

The exact nature of these differences is browser-dependent and poorly documented. They can include redundant namespace declarations on child elements, different attribute ordering, or namespace prefix handling that — while technically valid XML — trips up the browser's SVG-as-image renderer. The SVG-as-image rendering path (used when loading SVG via new Image()) is more restrictive than the inline SVG rendering path, and it may reject or silently ignore markup that inline rendering handles fine.

The key insight: the problem wasn't in the SVG structure, it was in the serialization roundtrip. The same logical SVG, when created via DOM APIs and serialized, produced different bytes than when created by the HTML parser and serialized — and those different bytes didn't survive the Image loading step.

The Fix

Once we identified that the DOM-to-string roundtrip was the culprit, the fix was almost trivially simple: don't do the roundtrip. Build the SVG string directly.

_buildSvgString(state) {
  const w = state.width;
  const h = state.height;
  const cropSize = Math.min(w, h);
  const cropX = (w - cropSize) / 2;
  const cropY = (h - cropSize) / 2;
  const rasterSize = Math.max(1024, Math.min(Math.ceil(cropSize), 4096));
  const fill = state.fillEnabled ? state.fill : 'none';

  // Escape path data for XML attribute context
  const d = (state.pathData || '')
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;');

  return `<svg xmlns="http://www.w3.org/2000/svg" width="${rasterSize}" height="${rasterSize}" viewBox="${cropX} ${cropY} ${cropSize} ${cropSize}"><rect width="${w}" height="${h}" fill="${state.background}"/><path d="${d}" stroke="${state.stroke}" stroke-width="${state.strokeWidth}" fill="${fill}"/></svg>`;
}

This string is passed directly to generateThumbnail via an options parameter, which skips the clone-and-serialize step entirely when a pre-built string is provided:

if (options?.svgString) {
  svgString = options.svgString;
} else {
  const clone = cloneSvgWithoutGrid(svgElement);
  // ... crop, resize, serialize as before
  svgString = new XMLSerializer().serializeToString(clone);
}

const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });

The existing paths (auto-save and manual crop) continue to use the DOM clone pipeline, which works because those SVG elements were created by the HTML parser in the first place. The admin backfill now bypasses the DOM entirely, producing clean SVG markup that the image renderer handles correctly.

Takeaways

XMLSerializer is not a lossless roundtrip. Creating SVG elements with createElementNS, setting attributes, then serializing with XMLSerializer does not necessarily produce the same output as writing that SVG as a string. The DOM is an abstraction layer, and serialization can introduce artifacts.

SVG-as-image rendering is stricter than inline SVG. Browsers apply tighter parsing and security restrictions when loading SVG via <img> or new Image(). Markup that renders perfectly as inline <svg> in your document may silently fail when loaded as an image.

When you control the input, skip the DOM. If you're generating SVG from known data (not cloning a live element), build the string directly. It's simpler, more predictable, and avoids an entire class of serialization edge cases. Template literals make this clean and readable.

The irony is that the "proper" approach — using the DOM API to construct a well-formed SVG element — was the one that broke. Sometimes the simplest tool is the right one.