SharePointVault ~/gotchas/spfx ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ image-promise-without-a-timeout-hangs-forever.md
sharepoint-vault›gotchas›spfx›image-promise-without-a-timeout-hangs-forever.mdhistoryedit on GitHub
● GOTCHAspfx· 3 min read

A Promise wrapped around img.onload can hang forever — try/catch won't save you

applies to: Any browser code that wraps an image, canvas or postMessage callback in a Promisereviewed: 2026-08-18#spfx#browser#canvas#svg#promise#testing

img.onload/onerror are not guaranteed to fire. Wrap them in a Promise with no timeout and the Promise never settles — so the caller's try/catch (and its reassuring "best-effort" comment) protects against an exception that never comes, while the user waits forever.

img.onload/onerror nemusí přijít NIKDY. Promise nad nimi bez časového stropu se pak nikdy nedokončí – volajícího try/catch chrání před výjimkou, ne před zaseknutím, a uživateli visí celá operace kvůli doplňku.

Symptom

You convert an SVG to PNG in the browser — chart export, thumbnail generation, "save as image":

export function svgToPngBlob(svg, width, height) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => { /* draw to canvas, canvas.toBlob(resolve) */ };
    img.onerror = () => reject(new Error('Could not render the image.'));
    img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
  });
}

The caller embeds the result into a generated page and is deliberately forgiving:

try {
  const blob = await svgToPngBlob(svg, w, h);
  await upload(blob);
} catch (e) {
  console.warn('chart embed failed:', e);   // best-effort: a chart must not break page creation
}

Then someone reports that creating the page never finishes. No error, no console output — a spinner that spins until the tab is closed.

Cause

Neither handler fires. Two ways to get there:

  • No canvas at all. In a headless/test environment (jsdom) Image exists but nothing decodes, so neither callback runs. This is how it usually gets discovered: unit tests time out instead of failing, which reads like "slow tests" rather than "the code has no way to finish."
  • In a real browser, a malformed SVG, a blocked data: URL (CSP), or a decode that never completes can leave the load in limbo.

The wider point: try/catch around await handles rejection. A Promise that never settles is not a rejection — control never returns, so no handler runs. The comment promising "best-effort" is then a claim the code cannot honour.

Fix

Give every callback-backed Promise a deadline, and route all exits through one guard so a late callback can't resolve after the timeout already rejected:

export function svgToPngBlob(svg, width, height) {
  return new Promise((resolve, reject) => {
    const st = { done: false, timer: undefined };
    const finish = (fn) => {
      if (st.done) return;
      st.done = true;
      clearTimeout(st.timer);
      fn();
    };
    st.timer = setTimeout(
      () => finish(() => reject(new Error('Image conversion timed out.'))),
      15000
    );
    try {
      const img = new Image();
      img.onload = () => {
        try { /* … */ finish(() => resolve(blob)); }
        catch (e) { finish(() => reject(e)); }
      };
      img.onerror = () => finish(() => reject(new Error('Could not render the image.')));
      img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
    } catch (e) { finish(() => reject(e)); }
  });
}

Holding timer in an object avoids the declaration cycle you get from let timer + a finish that reads it (ESLint no-use-before-define vs. prefer-const will otherwise contradict each other).

Why it's easy to miss

  • A timing-out test looks like a slow test. The instinct is to raise the timeout, which hides the defect permanently. Treat "it timed out" as "something never completes" until proven otherwise.
  • The surrounding try/catch makes the call site look defensive, and code review reads the comment rather than asking what happens when the callback simply doesn't arrive.
  • It only bites on the unhappy path, which is rare in a browser — so it can ship and sit for years.

See also

  • Same class: any Promise over onload, onerror, onmessage, postMessage, or a <script> tag injection. If nothing guarantees the callback, nothing guarantees the Promise settles.
  • gotchas/spfx/office-file-extraction-needs-a-decompressed-size-cap.md — the other half of "client-side conversion needs a hard limit", there on size rather than time.
Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-08-18last updated: 2026-09-24historyedit on GitHubview source