Posted 29 August 2025, 12:33 pm EST
- Updated 29 August 2025, 2:18 pm EST
Our Use Case
Ensuring SpreadJS workbook is fully painted (canvas) before Puppeteer PDF capture in headless Chromium with multiple instances of SpreadJS
We’re using SpreadJS inside a Vue 3 SPA and need a reliable way to know when a workbook / active sheet is fully rendered (painted to canvas) before triggering a headless Chromium PDF capture (Puppeteer 22.x) running in AWS Lambda.
In browsers this works all the time, but in Lambda (headless Chromium) with several SpreadJS instances on the same page (3+ widgets) we see a race condition: some instances render fully and others never finish before we capture, even though the ‘import’ success callbacks run. We currently use a fixed timeout after ‘spread.resumePaint()’ but it’s unreliable.
Below are environment details, simplified code excerpts, observed behaviour and specific questions.
Environment / Context
- Vue 3 single page app; SpreadJS loaded and used inside a dynamic Vue component (one SpreadJS workbook per widget).
- Headless PDF export happens on AWS Lambda using headless Chromium via Puppeteer (Puppeteer 22.x).
- SpreadJS is dynamically imported (to avoid bundling/loading all modules at start).
- We use “lazy”/“incremental” patterns and try to optimise for headless PDF generation (disabled interactive features, suspend/resume painting, etc).
- Typical problematic input: moderately complex Excel files (1–2 MB, 10–20 sheets).
- Problem becomes much more likely when rendering 3+ instances concurrently on the same page.
How we load SpreadJS (dynamic)
const loadSpreadJSModules = async () => {
if (GC) return GC; // Already loaded
try {
// Step 1: Import the core SpreadJS module first
const spreadModule = await import("@mescius/spread-sheets");
// Step 2: Load feature plugins first (Shapes before Charts)
await import("@mescius/spread-sheets-shapes");
await import("@mescius/spread-sheets-charts");
// Step 3: Load the ExcelIO plugin last, as it depends on the others
await import("@mescius/spread-sheets-io");
GC = spreadModule;
return GC;
} catch (error) {
console.error("Failed to load SpreadJS modules:", error);
throw error;
}
};
How we import files
spread.import(
blob,
// Success callback
function () {
handleImportSuccess("Blob");
},
// Error callback
function (error: any) {
handleImportError(error);
},
// Options with performance settings
importOptions,
);
Simplified ‘handleImportSuccess’ (current flow)
const handleImportSuccess = async (method: string) => {
try {
// Resume calculation services
try { spread.resumeCalcService(false); } catch (e) {}
// Apply a bunch of settings (gridlines, autofilter, visibility cropping, etc)
try { await applySpreadSettings(); } catch (e) { throw e; }
// A font family update that wraps suspendPaint/resumePaint
try { updateFontFamily(); } catch (e) {}
// Hide scrollbars for PDF mode
spread.options.showHorizontalScrollbar = false;
spread.options.showVerticalScrollbar = false;
// Resume painting (we previously suspended paint during import & settings)
try { spread.resumePaint(); } catch (e) {}
// Current heuristic: wait a fixed delay, then emit "render-complete"
try {
setTimeout(() => {
emit("render-complete");
}, 3000); // 3s delay (we've tried 5s/15s/25s)
} catch (e) { throw e; }
} catch (error) {
console.error(error);
}
};
Workbook options used on creation (we attempt to reduce overhead)
const workbookOptions = {
sheetCount: 1,
calcOnDemand: true,
incrementalCalculation: true,
scrollByPixel: false,
allowAutoExtendFilterRange: false,
allowUndo: false,
enableAccessibility: false,
showDragDropTip: false,
showDragFillTip: false,
// For PDF mode we also set:
allowUserResize: false,
allowUserZoom: false,
allowContextMenu: false,
tabEditable: false,
newTabVisible: false,
allowSheetReorder: false,
tabStripVisible: false,
showResizeTip: false,
highlightInvalidData: false,
};
Observed behaviour
- In normal browsers:
- import callback → apply settings → resumePaint → fixed 3s wait reliably produces a fully rendered canvas for many instances (3+).
- In headless Chromium (Puppeteer on Lambda):
- With 1–2 moderately complex files (1–2 MB, 10–20 sheets) rendering is usually OK.
- With 3+ SpreadJS instances on the same page we sometimes complete only 1–2 instances; other instances never reach “render-complete” even though import success and settings logs exist for them.
- Increasing timeouts (5s, 15s, 25s) did not fully solve the problem.
- ‘requestAnimationFrame’-based approaches did not help in headless (no reliable rAF loop).
- The stuck widgets’ debug logs typically stop after applying settings / starting import — they do not reach the “paint-resumed” / “render-complete” steps.
Example debug timelines
What we’ve tried so far (to mitigate)
- Suspended paint & calc during import, applied settings while suspended, then resumed paint & calc in a controlled order.
- Tried “incremental” and “lazy” performance modes / workbook options to reduce CPU and memory pressure.
- Tried rAF-based waits (does not work reliably in headless).
- Tried fixed timeouts (5s, 15s, 25s) — inconsistent.
- Attempted sequential rendering (queuing) logic in our app to render one widget at a time — reduced but did not eliminate the issue.
Specific questions for SpreadJS support
- Does SpreadJS provide any internal event/callback that reliably indicates “canvas painting is complete” for the active sheet (beyond the ‘import’ success callback)? For instance, is there a documented lifecycle or API to detect final paint completion after calling ‘resumePaint()’?
- Is there a method to force synchronous painting or a “flush” call (e.g., ‘repaint()’, ‘flushRender’, ‘finishDrawing’) on the workbook or sheet API that ensures drawing operations are complete?
- Is there an internal workbook/sheet state or flag we can poll which reliably indicates that layout/drawing work is done (e.g., something exposed on ‘GC.Spread.Sheets.Workbook’ or the sheet object)?
- Are there recommended practices or configuration flags when running multiple SpreadJS instances in the same page (headless environment) to avoid race conditions or incomplete renders? Examples: forced sequential rendering, throttling concurrency, preferred workbook options for headless rendering, or memory/CPU settings.
- Is the interplay of ‘suspendPaint()’ / ‘resumePaint()’ and ‘suspendCalcService()’ / ‘resumeCalcService()’ known to be sensitive in headless Chromium? Is there a recommended ordering (resumeCalcService before resumePaint, or vice versa) that you guarantee will let canvases finish drawing?
- For our import + settings flow, what sequence do you recommend (import → resumeCalcService → apply settings → resumePaint → ???) to guarantee canvases are ready for capture? Any actions we should add after ‘resumePaint()’ to ensure completeness?
- Are there any known issues with Puppeteer 22.x / headless Chromium capturing SpreadJS canvases in Lambda? Are there recommended Chromium flags, Puppeteer settings, or Chromium versions that improve canvas rendering reliability for SpreadJS in headless mode?
- Is there a recommended SpreadJS version (or a patch) which provides better guarantees for headless rendering or emits a “render-complete” type event?
- If you recommend sequential rendering / queueing as the solution, what signal should the queue wait for? Is ‘file-loaded’ + ‘resumePaint()’ + X ms sufficient (and what X would you suggest for headless Lambda under moderate CPU), or is there a better internal signal to wait on?
- The Lambda Service has 8gb of RAM and 2 minute timeout.
Thanks.