ScreenshotNeo

BlogHTML to image & PDF

How to Prevent Duplicated Data on html2canvas and jsPDF Page Breaks

Find whether repeated PDF content starts in your DOM, canvas capture, or page placement, then fix the correct stage.

By the ScreenshotNeo team30 September 20269 min read

How to Prevent Duplicated Data on html2canvas and jsPDF Page Breaks

Repeated rows, cards, or sections around a PDF page break usually have one of three causes: the source DOM contains duplicates, html2canvas captured an unexpected or clipped layout, or the same canvas strip was placed on more than one jsPDF page. The reliable fix is to locate the first stage where repetition appears. Inspect the DOM, inspect the canvas, then inspect PDF page placement. Do not start by assuming jsPDF itself is broken.

This guide gives a diagnostic workflow, a small reproducible implementation, page-break controls for html2pdf.js, and fixes for capture dimensions, cloning, offsets, scaling, and page loops.

1. Locate the first stage that repeats

  1. Inspect the DOM. Before calling html2canvas, query the export root and verify that each row or section exists once. Check application state, list keys, virtualization, duplicated print-only markup, and code that appends the same template during export.
  2. Inspect the canvas. Render the element to a canvas and open that image separately. html2canvas resolves a Promise with a canvas; it is a browser capture library, not a server-side Node.js renderer. Read the html2canvas documentation.
  3. Inspect PDF placement. If the canvas has one copy but the PDF repeats a band, the issue is in page-height arithmetic, source offsets, scale conversion, or the loop that calls addPage().

Change one variable at a time. Keep the DOM and content fixed, save the intermediate canvas, and compare the output after each change. This tells you whether a change affects capture or only PDF pagination.

Find the first stage where the repeated block appears: DOM, canvas, or PDF placement.
Find the first stage where the repeated block appears: DOM, canvas, or PDF placement.

2. Minimal diagnostic capture

Start with a known export root and save the canvas before involving jsPDF:

const root = document.querySelector('#invoice');
if (!root) throw new Error('Export root #invoice was not found');

const canvas = await html2canvas(root, {
  backgroundColor: '#ffffff',
  windowWidth: root.scrollWidth,
  windowHeight: root.scrollHeight,
  logging: true
});

document.body.appendChild(canvas); // temporary visual check
console.log({
  canvasWidth: canvas.width,
  canvasHeight: canvas.height,
  scrollWidth: root.scrollWidth,
  scrollHeight: root.scrollHeight
});

If the duplicate is visible in this canvas, stay in the DOM or capture stage. If the canvas is correct, remove the temporary canvas and debug only the PDF page loop.

3. Check the DOM and application state

Repeated-looking PDF content can be legitimate duplicated markup. Common examples include a screen table and a print table rendered together, a React/Vue component mounted twice, an export function that appends rows on every click, or a virtualized list whose off-screen and visible copies overlap.

Use a deterministic export root

const root = document.querySelector('[data-pdf-root]');
const blocks = [...root.querySelectorAll('[data-row]')];
const ids = blocks.map(block => block.dataset.row);
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
if (duplicates.length) {
  throw new Error(`Duplicate rows in DOM: ${duplicates.join(', ')}`);
}

Freeze data before capture so asynchronous updates cannot change the layout halfway through. Disable animations and blinking cursors in an export class. Avoid selecting body when a smaller, dedicated root is available; global banners, sticky headers, and hidden duplicate templates can then enter the clone.

Exclude known unwanted elements

Add data-html2canvas-ignore to controls, live chat, transient notifications, or export buttons that should not be painted. You can also remove or alter nodes in html2canvas’s cloned document with onclone. These options are documented in the configuration reference.

const canvas = await html2canvas(root, {
  onclone: (clonedDocument) => {
    clonedDocument.querySelectorAll('.toast, .chat-widget, .export-button')
      .forEach(node => node.remove());
    clonedDocument.documentElement.classList.add('pdf-export');
  }
});

The callback changes the cloned document, not your live page. If a mutation script adds a second copy, log the clone’s row count inside onclone and inspect the export-only CSS.

4. Diagnose canvas clipping and dimensions

A clipped canvas can look like duplication when the PDF loop repeatedly places the last visible strip. Browser canvas size limits can leave a canvas empty or cut off partway through. The html2canvas FAQ recommends matching windowWidth and windowHeight to the element’s scroll dimensions when the document is larger than the viewport. Treat that as a capture diagnostic, not a universal duplicate fix. See the html2canvas FAQ.

const width = Math.ceil(root.scrollWidth);
const height = Math.ceil(root.scrollHeight);
const canvas = await html2canvas(root, {
  windowWidth: width,
  windowHeight: height,
  width,
  height,
  scrollX: 0,
  scrollY: 0,
  scale: Math.min(window.devicePixelRatio, 2)
});

if (canvas.width === 0 || canvas.height === 0) {
  throw new Error('Canvas is empty; check browser canvas limits and dimensions');
}

For a very tall report, test a shorter fixture first. If the short fixture works and the full report fails, measure the canvas dimensions and split the capture into page-sized regions or reduce the scale. A smaller scale lowers memory use but also lowers output resolution.

5. Correct jsPDF page placement

A common custom approach captures one tall canvas, converts it to an image, and places vertical slices on PDF pages. The critical rule is that each page must use a new source Y offset. Keep units explicit and calculate the source step from the rendered image height.

import { jsPDF } from 'jspdf';

async function exportPdf() {
  const root = document.querySelector('#invoice');
  const canvas = await html2canvas(root, {
    windowWidth: root.scrollWidth,
    windowHeight: root.scrollHeight,
    backgroundColor: '#fff'
  });

  const pdf = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' });
  const pageWidth = pdf.internal.pageSize.getWidth();
  const pageHeight = pdf.internal.pageSize.getHeight();
  const margin = 10;
  const imageWidth = pageWidth - margin * 2;
  const imageHeight = canvas.height * imageWidth / canvas.width;
  const usableHeight = pageHeight - margin * 2;
  const sourcePixelsPerPage = usableHeight * canvas.width / imageWidth;
  const imageData = canvas.toDataURL('image/jpeg', 0.92);

  let sourceY = 0;
  let page = 0;
  while (sourceY < canvas.height) {
    if (page > 0) pdf.addPage();
    const renderedY = margin - sourceY * imageWidth / canvas.width;
    pdf.addImage(imageData, 'JPEG', margin, renderedY, imageWidth, imageHeight);
    sourceY += sourcePixelsPerPage;
    page += 1;
  }
  pdf.save('invoice.pdf');
}

This method intentionally positions the same tall image at a progressively negative Y coordinate. If sourceY is reset, rounded incorrectly, or advanced by PDF millimetres instead of canvas pixels, every page can show the same region. Log sourceY, sourcePixelsPerPage, and the calculated Y position for each page.

Prefer page-sized captures for difficult layouts

Instead of one extremely tall canvas, render one page-sized region at a time. Add a temporary page wrapper, set its dimensions, capture it, then remove it. This costs more capture calls but avoids browser canvas limits and makes page boundaries explicit. It is an engineering option, not a universal recommendation; choose it when the report is too tall or contains layout that changes during a single long capture.

6. Control page breaks with html2pdf.js

html2pdf.js has CSS and selector-based page-break controls. Its documented modes can honor break-before, break-after, and break-inside; options also support explicit before, after, and avoid selectors. The project README lists cloning and root-element resizing or reflow as known implementation concerns. Review those behaviors when the cloned layout differs from the live page. See the html2pdf.js README.

const options = {
  margin: [12, 12, 12, 12],
  image: { type: 'jpeg', quality: 0.95 },
  html2canvas: {
    scale: 2,
    useCORS: true,
    windowWidth: document.querySelector('#invoice').scrollWidth
  },
  jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
  pagebreak: {
    mode: ['css', 'legacy'],
    before: '.start-on-new-page',
    after: '.end-page',
    avoid: ['tr', '.card', '.keep-together']
  }
};

html2pdf().set(options).from(document.querySelector('#invoice')).save();

Use CSS deliberately:

.start-on-new-page { break-before: page; }
.end-page { break-after: page; }
.card, tr, .keep-together { break-inside: avoid; }

If avoid-all causes large blank areas or unexpected movement, remove it and add targeted selectors. Compare CSS-only, selector-only, and combined modes while keeping the source content fixed.

7. Common symptoms, causes, and fixes

Symptom Likely cause Fix
Duplicate already appears in canvas DOM has two copies or clone mutation adds one Count nodes, inspect onclone, remove export-only duplicates
Canvas is cut off, then PDF seems repeated Browser canvas limit or incorrect dimensions Compare canvas and scroll dimensions; reduce scale or capture page regions
First PDF page is correct; later pages repeat it Source Y offset never advances Log page offsets and increment by the calculated source pixels per page
Thin strip repeats at every boundary Rounding or overlap between slice calculations Use one unit conversion, avoid cumulative rounded values, and inspect adjacent offsets
Rows move or duplicate only with html2pdf.js Clone or root resizing reflows layout Inspect cloned dimensions; set explicit widths and test page-break modes separately
Blank PDF Capture ran before fonts/images/layout finished Wait for fonts and images, verify nonzero canvas dimensions, then export
Content missing after a page break break-inside: avoid or selector rules move it Remove broad avoid rules and apply them only to atomic blocks
Different result after clicking export twice Export code appended temporary nodes repeatedly Use a finally cleanup and replace, rather than append, temporary wrappers

8. A repeatable troubleshooting checklist

  1. Reload and capture a static fixture so prior export nodes are gone.
  2. Count the source rows, sections, and cards.
  3. Wait for document.fonts.ready and all required images.
  4. Capture the smallest export root with html2canvas.
  5. Open the canvas independently and record its dimensions.
  6. Compare scrollWidth/scrollHeight with canvas dimensions.
  7. Inspect the cloned document and any onclone mutations.
  8. Disable custom page-break rules and test one page.
  9. Re-enable CSS and selector rules one at a time.
  10. For custom jsPDF loops, log every source offset and page number.
  11. Test a short report, then a long report, then the production report.

A jsPDF issue report describes content breaking at page transitions in a multipage html2canvas workflow. Treat it as an individual report, not proof of a general jsPDF defect or a universal fix. Review jsPDF issue discussions alongside your own intermediate output.

9. Performance, reliability, and cost considerations

Large DOM trees increase layout and rasterization time. Capture only the required root, remove animations, and avoid an unnecessarily high scale. Higher scale improves sharpness but increases canvas memory and PDF size. JPEG reduces size for photographic content; PNG preserves text-like edges and transparency but can be larger.

A hosted capture can remove common overlays before producing the screenshot or PDF.
A hosted capture can remove common overlays before producing the screenshot or PDF.

Wait for fonts and images before capture, and use a deterministic viewport. If external images are missing, check CORS headers and useCORS; a failed resource can change layout even when it does not create duplication. Keep export cleanup in a finally block so a failed capture does not leave hidden nodes that affect the next attempt.

For recurring server jobs, browser-side html2canvas and jsPDF require a real browser environment and careful memory limits. A hosted screenshot service can move browser setup, waiting, authentication, and PDF capture out of your application.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF. It can wait for a selector, delay, or network idle; load lazy images for full-page captures; capture one CSS-selected element; apply custom CSS or JavaScript; set viewport, device, dark mode, cookies, headers, user agent, timezone, geolocation, and PDF paper settings.

Before capture, cookie and consent banners, newsletter popups, and chat widgets are removed. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers identify the page verdict and whether it was billed. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Every feature is included on every plan. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Is repeated content always a jsPDF bug?

No. Repetition can begin in the DOM, cloned document, canvas, or page-placement loop. The intermediate canvas identifies the boundary.

Should I always set windowWidth and windowHeight?

Use the element’s scroll dimensions when diagnosing clipping or incomplete captures. Those settings do not guarantee a fix for duplicated content.

Does html2canvas run in Node.js?

It is a browser library that captures a DOM element and resolves a canvas. A Node process needs a browser renderer or a hosted capture service.

When should I split a long capture?

Consider page-sized captures when a single canvas approaches browser limits, consumes too much memory, or is difficult to place accurately. Test against your layout because separate captures can change sticky or responsive behavior.

How can I keep a table row together?

Apply break-inside: avoid or an html2pdf.js avoid selector to the row or containing block, then verify that the rule is not creating unacceptable blank space.