DEV Community

Cover image for Debugging window.print() on Mobile: The Phantom Blank Page and the Anti-Pattern Behind It
Valentin Manier
Valentin Manier

Posted on • Edited on

Debugging window.print() on Mobile: The Phantom Blank Page and the Anti-Pattern Behind It

PDF export with window.print() and Puppeteer: a 3-day debugging diary

On Lìonra, my free resume builder, PDF export relies on window.print() rather than html2canvas + jsPDF or a template-based service. That choice isn't arbitrary: html2canvas doesn't faithfully render complex CSS (custom fonts, gradients, grid backgrounds), and a template-based tool would mean duplicating every resume theme by hand.

This post documents the full journey: a mobile blank page bug, its fix, and what happened when I moved to server-side PDF generation with Puppeteer.


Part 1 : The mobile blank page

The starting code

function exportPDF() {
  const preview = document.getElementById("preview");
  const contentH = preview.scrollHeight;
  const scale = Math.min(1123 / contentH, 1);

  Array.from(preview.children).forEach(el => {
    el.style.zoom = scale;
  });

  window.print();
}
Enter fullscreen mode Exit fullscreen mode
@media print {
  html, body {
    width: 210mm !important;
    height: 297mm !important;
    overflow: hidden !important;
  }
  .preview {
    width: 210mm !important;
    height: 297mm !important;
    overflow: hidden !important;
  }
  @page { size: A4 portrait; margin: 0; }
}
Enter fullscreen mode Exit fullscreen mode

Worked perfectly on desktop Chrome and Edge. On iOS Safari and Chrome Android: a blank second page appeared every time.

Round 1 False lead: the zoom calculation

First instinct: the PDF button is reachable from any mobile tab. If the user taps it while the Editor tab is active, the preview panel has display: none, so scrollHeight returns a wrong value.

const wasPanelHidden = previewPanel.classList.contains("panel-hidden");
if (wasPanelHidden) {
  previewPanel.classList.remove("panel-hidden");
  preview.offsetHeight; // force reflow
}
Enter fullscreen mode Exit fullscreen mode

Result: still a blank page. The fix was worth keeping (it corrected a real latent bug), but it wasn't the root cause.

Round 2 The real cause: fixed height + overflow:hidden in print

body { height: 297mm !important; overflow: hidden !important; }
Enter fullscreen mode Exit fullscreen mode

This is a well-documented anti-pattern that I didn't know about. On mobile print engines (WebKit iOS, Chrome Android), a fixed height combined with overflow: hidden causes the browser to reserve an extra blank page as a safety measure, even when content doesn't actually overflow.

There's a second factor: body had min-height: 100vh from screen mode. On mobile, 100vh can far exceed 297mm. And min-height always wins over height when it's greater, not a specificity issue, just box model math.

Round 3 Radical fix, incomplete

@media print {
  html, body {
    height: auto !important;
    min-height: 0 !important;
    overflow: visible !important;
  }
  .preview {
    height: auto !important;
    overflow: visible !important;
  }
}
Enter fullscreen mode Exit fullscreen mode

No more blank page. But now the resume doesn't fill the A4 sheet ,a large white band appears at the bottom.

Round 4 The actual fix

Key insight: not every element plays the same role. html, body, intermediate containers → height: auto. .preview, the only element carrying the theme background → fixed 297mm, safe because its content is already scaled down by the JS zoom.

@media print {
  html, body {
    height: auto !important;
    min-height: 0 !important;
    overflow: visible !important;
  }
  .workspace, .preview-panel {
    height: auto !important;
    overflow: visible !important;
  }
  .preview {
    width: 210mm !important;
    height: 297mm !important;
    min-height: 297mm !important;
    overflow: hidden !important;
    -webkit-print-color-adjust: exact !important;
    print-color-adjust: exact !important;
  }
  @page { size: A4 portrait; margin: 0; }
}
Enter fullscreen mode Exit fullscreen mode

Validated with Playwright across 3 scenarios: Editor tab active, Preview tab active, artificially bloated content. Zero blank pages injected.


Part 2 : Moving to Puppeteer for server-side generation

window.print() depends on the user's browser behavior. I wanted a consistent, server-controlled PDF. Enter Puppeteer.

The unit trap: 595px vs 794px

First mistake: using 595px for the viewport width.

  • 595px = A4 in PDF points (72dpi)
  • 794px = A4 in CSS pixels (96dpi, what Chromium actually uses)

Puppeteer renders in CSS pixels. Using 595px gives you a viewport 25% too narrow. All scrollHeight measurements are wrong, and the content looks cramped.

const A4_W = 794; // CSS px at 96dpi
const A4_H = 1123;

await page.setViewport({ width: A4_W, height: 4000 });
Enter fullscreen mode Exit fullscreen mode

For page.pdf(), use format: "A4" or explicit mm, never px values, as Puppeteer interprets those as PDF points.

The white band problem

Even with the correct viewport, a white band appeared on the right side of the PDF. Diagnosis:

// Puppeteer-side debug
const debug = await page.evaluate(() => {
  const el = document.querySelector("#_cv-root > *");
  return {
    offsetWidth: el.offsetWidth,        // 794 ✓
    computedMaxW: getComputedStyle(el).maxWidth, // "720px" ✗
    computedMargin: getComputedStyle(el).margin, // "0px auto" ✗
  };
});
Enter fullscreen mode Exit fullscreen mode

The .preview CSS class had max-width: 720px; margin: 0 auto. Even though inline styles forced width: 794px, the max-width from the stylesheet still applied (inline styles can't override a stylesheet max-width when the inline width is already at its max).

Fix in the Puppeteer HTML:

/* Injected after ${css} in measureHtml */
.preview, .pdf-export {
  width: ${A4_W}px !important;
  max-width: ${A4_W}px !important;
  margin: 0 !important;
}
Enter fullscreen mode Exit fullscreen mode

CSS zoom leaves white bands

Once width was fixed, the zoom strategy for tall content created new white bands:

// WRONG: CSS zoom reduces content visually but not its layout footprint
document.documentElement.style.zoom = zoom;
Enter fullscreen mode Exit fullscreen mode

CSS zoom scales the element visually but the element still occupies its original size in the document flow. Combined with format: "A4", Puppeteer centers the zoomed content → white bands on right and bottom.

The iterative padding reduction strategy

The real solution: instead of scaling the whole element, progressively reduce internal spacing until content fits A4_H:

let currentH = await page.evaluate(() =>
  document.getElementById("_cv-root").scrollHeight
);

if (currentH > A4_H) {
  let iteration = 0;
  while (currentH > A4_H && iteration < 15) {
    await page.evaluate(() => {
      const cv = document.querySelector("#_cv-root > *");

      // Reduce wrapper padding
      const pt = parseFloat(getComputedStyle(cv).paddingTop);
      const pb = parseFloat(getComputedStyle(cv).paddingBottom);
      cv.style.paddingTop    = Math.max(4, pt - 10) + "px";
      cv.style.paddingBottom = Math.max(4, pb - 10) + "px";

      // Reduce section margins
      cv.querySelectorAll(".preview-section").forEach(s => {
        const mb = parseFloat(getComputedStyle(s).marginBottom);
        s.style.marginBottom = Math.max(2, mb - 2) + "px";
      });

      // Reduce card padding
      cv.querySelectorAll(".item-card").forEach(c => {
        const pt2 = parseFloat(getComputedStyle(c).paddingTop);
        c.style.paddingTop    = Math.max(3, pt2 - 2) + "px";
        c.style.paddingBottom = Math.max(3, pt2 - 2) + "px";
      });
    });

    currentH = await page.evaluate(() =>
      document.getElementById("_cv-root").scrollHeight
    );
    iteration++;
  }

  // Zoom only as last resort if still overflowing
  if (currentH > A4_H) {
    const zoom = A4_H / currentH;
    await page.evaluate(({ zoom, A4_W, A4_H }) => {
      document.documentElement.style.zoom = zoom;
      document.body.style.width  = A4_W + "px";
      document.body.style.height = A4_H + "px";
      document.body.style.overflow = "hidden";
    }, { zoom, A4_W, A4_H });
    currentH = A4_H;
  }
}

// Fill bottom gap with minHeight (preserves element positions)
if (currentH < A4_H) {
  await page.evaluate(({ A4_H }) => {
    const cv = document.querySelector("#_cv-root > *");
    cv.style.minHeight = A4_H + "px";
  }, { A4_H });
}
Enter fullscreen mode Exit fullscreen mode

Width stays at 794px throughout. No zoom, no white bands.


Key takeaways

  1. Never lock html or body to a fixed height in @media print it causes blank pages on mobile, even when content fits.
  2. min-height always beats height in box model calculation, regardless of !important on both sides.
  3. Only the background-bearing element needs a fixed A4 height neutral containers stay at height: auto.
  4. Puppeteer renders at 96dpi use 794×1123px for A4, never 595×842px.
  5. CSS zoom ≠ layout reduction it scales visually but not in the flow; use iterative spacing reduction instead.
  6. minHeight to fill, not paddingBottom adding padding pushes absolutely-positioned elements (like a location footer) off-screen.
  7. Always test the overflow edge case fixing the normal case can break the "content too tall" scenario.

Building Lìonra in public. If you're working on print CSS or Puppeteer PDF generation and hitting similar issues, feel free to reach out.

Top comments (0)