← Back to Blog
React2026-05-12·85 min read

Generating LinkedIn Carousels as Multi-Page PDFs With Puppeteer

By Nicolas Lecocq

Automating LinkedIn Document Carousels: A Server-Side PDF Rendering Pipeline

Current Situation Analysis

Social media automation pipelines and content management systems frequently encounter a silent blocker when targeting LinkedIn: the platform's carousel format is fundamentally misunderstood. Engineering teams routinely assume carousels are sequential image uploads, mirroring Instagram or Twitter's media handling. This assumption triggers a cascade of failed uploads, format rejections, and wasted development cycles. The root cause lies in platform documentation that uses the term "carousel" while the underlying renderer strictly expects a multi-page PDF document attached to a share payload.

This mismatch is rarely caught during early prototyping because developers test with static assets or rely on third-party wrappers that abstract the file type. When building from scratch, the discrepancy becomes apparent only after multiple API rejections. The technical reality is that LinkedIn's feed engine slices a PDF document into swipeable slides, applying its own overlay and pagination logic. Each slide must conform to a strict 1080x1350 pixel viewport. Deviations trigger automatic scaling, blurry text, or outright rejection.

The problem is compounded by deployment constraints. Headless browser rendering requires a Chromium binary, which typically exceeds 170MB. Modern serverless platforms enforce function size limits around 50MB, forcing engineers into architectural compromises. Additionally, PDF generation introduces memory pressure, font loading race conditions, and cold-start latency that can push render times beyond acceptable thresholds for real-time content generation. Without a structured pipeline, teams end up patching together image-to-PDF converters, rasterizing HTML with inconsistent scaling, or paying premium rates for managed rendering services.

Understanding the PDF-native workflow transforms the problem from a platform quirk into a deterministic rendering pipeline. By treating each slide as a CSS-styled HTML page, rendering them individually, merging them efficiently, and routing them through LinkedIn's document upload flow, teams can achieve reliable, scalable carousel generation with full control over typography, layout, and asset delivery.

WOW Moment: Key Findings

The shift from image-based sequencing to PDF-native generation resolves multiple architectural bottlenecks simultaneously. The following comparison highlights why the PDF approach outperforms traditional raster workflows in production environments:

Approach Render Fidelity Deployment Footprint API Compatibility Cold Start Latency
Image Sequence (PNG/JPG) Variable (depends on canvas scaling) Low (no browser needed) Requires manual pagination & overlay handling <1s
Monolithic HTML-to-PDF High (single render pass) High (full Chromium) Native LinkedIn document support 4-6s
Split-Render + Merge High (per-page isolation) Medium (slim Chromium) Native LinkedIn document support 4-6s
Managed Rendering API High (vendor optimized) Near-zero Native LinkedIn document support 2-4s (network dependent)

The split-render strategy combined with PDF merging delivers the highest reliability. Rendering each slide in an isolated browser context prevents cross-slide CSS leakage, memory fragmentation, and font loading conflicts. Merging via a lightweight PDF manipulation library takes milliseconds, while preserving exact page dimensions and vector text quality. This approach also aligns perfectly with LinkedIn's document ingestion pipeline, eliminating the need for custom pagination overlays or aspect-ratio workarounds.

The finding matters because it decouples content generation from platform-specific constraints. Engineers can iterate on HTML/CSS templates, leverage existing design systems, and deploy to cost-efficient serverless infrastructure without sacrificing render quality or API compliance.

Core Solution

Building a production-ready carousel pipeline requires four coordinated stages: template generation, isolated headless rendering, document assembly, and platform upload. Each stage must account for serverless constraints, memory limits, and LinkedIn's strict document specifications.

1. Template Generation with Exact Page Dimensions

LinkedIn expects each slide to occupy a 1080x1350 pixel viewport. The most reliable way to enforce this is through CSS @page rules rather than relying on browser viewport scaling. The template should strip all default margins, set explicit dimensions, and use system fonts or preloaded web fonts to avoid layout shifts.

interface CarouselPageProps {
  pageIndex: number;
  totalPages: number;
  heading: string;
  content: string;
  accentColor: string;
}

function buildPageMarkup(props: CarouselPageProps): string {
  const { pageIndex, totalPages, heading, content, accentColor } = props;
  return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <style>
        @page { size: 1080px 1350px; margin: 0; }
        html, body {
          margin: 0; padding: 0;
          width: 1080px; height: 1350px;
          font-family: 'Inter', system-ui, -apple-system, sans-serif;
          background: #ffffff;
        }
        .slide-container {
          width: 100%; height: 100%;
          display: flex; flex-direction: column;
          padding: 80px;
          box-sizing: border-box;
          border-top: 8px solid ${accentColor};
        }
        .heading {
          font-size: 52px; font-weight: 700;
          line-height: 1.15; color: #0f172a;
          margin-bottom: 32px;
        }
        .content {
          font-size: 26px; line-height: 1.5;
          color: #334155; flex-grow: 1;
        }
        .pagination {
          font-size: 16px; color: #64748b;
          text-align: right; margin-top: 24px;
        }
      </style>
    </head>
    <body>
      <div class="slide-container">
        <h1 class="heading">${heading}</h1>
        <p class="content">${content}</p>
        <div class="pagination">${pageIndex + 1} / ${totalPages}</div>
      </div>
    </body>
    </html>
  `;
}

Architecture Rationale: Using @page with zero margins guarantees Puppeteer respects the exact dimensions. The box-sizing: border-box and explicit padding prevent overflow clipping. Custom pagination is included because LinkedIn's native overlay misaligns on mobile viewports.

2. Isolated Headless Rendering

Rendering all slides in a single browser context introduces memory bloat and unpredictable CSS inheritance. Instead, spawn a fresh page per slide, render the markup, extract the PDF buffer, and close the page. This keeps memory usage linear and prevents cross-contamination.

import puppeteer from "puppeteer-core";
import type { Browser, Page } from "puppeteer-core";

interface RenderOptions {
  browser: Browser;
  htmlMarkup: string;
}

async function renderSingleSlide({ browser, htmlMarkup }: RenderOptions): Promise<Buffer> {
  const page: Page = await browser.newPage();
  
  try {
    await page.setContent(htmlMarkup, { waitUntil: "networkidle0" });
    
    const pdfBuffer = await page.pdf({
      format: undefined,
      width: "1080px",
      height: "1350px",
      printBackground: true,
      preferCSSPageSize: true,
      margin: { top: "0", right: "0", bottom: "0", left: "0" }
    });

    return Buffer.from(pdfBuffer);
  } finally {
    await page.close();
  }
}

Architecture Rationale:

  • preferCSSPageSize: true forces Puppeteer to honor the @page rule instead of defaulting to A4.
  • printBackground: true ensures CSS backgrounds and borders render correctly.
  • waitUntil: "networkidle0" guarantees fonts and external assets finish loading before rasterization.
  • Explicit margin overrides prevent Puppeteer from injecting default print margins.

3. Document Assembly

Individual PDF buffers are merged using pdf-lib, a fast, zero-dependency PDF manipulation library. This approach is significantly more reliable than attempting multi-page generation in a single Puppeteer session.

import { PDFDocument } from "pdf-lib";

async function assembleCarousel(pages: Buffer[]): Promise<Uint8Array> {
  const masterDoc = await PDFDocument.create();
  
  for (const pageBuffer of pages) {
    const sourceDoc = await PDFDocument.load(pageBuffer);
    const [copiedPage] = await masterDoc.copyPages(sourceDoc, [0]);
    masterDoc.addPage(copiedPage);
  }
  
  return masterDoc.save();
}

Architecture Rationale: pdf-lib operates entirely in memory with minimal overhead. Copying pages preserves vector text, embedded fonts, and exact dimensions. The merge operation typically completes in under 50ms for a 10-slide carousel.

4. LinkedIn Document Upload Flow

LinkedIn's document API requires a three-step handshake: initialize the upload, stream the binary payload, and attach the returned document URN to a share payload.

interface LinkedInUploadResponse {
  value: {
    uploadUrl: string;
    document: string;
  };
}

async function uploadToLinkedIn(
  pdfBytes: Uint8Array,
  authToken: string,
  authorUrn: string
): Promise<string> {
  const initResponse = await fetch(
    "https://api.linkedin.com/rest/documents?action=initializeUpload",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${authToken}`,
        "LinkedIn-Version": "202401",
        "Content-Type": "application/json",
        "x-li-format": "json"
      },
      body: JSON.stringify({
        initializeUploadRequest: { owner: authorUrn }
      })
    }
  );

  if (!initResponse.ok) {
    throw new Error(`LinkedIn init failed: ${initResponse.status}`);
  }

  const { value } = (await initResponse.json()) as LinkedInUploadResponse;
  
  const uploadResponse = await fetch(value.uploadUrl, {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${authToken}`,
      "Content-Type": "application/octet-stream"
    },
    body: Buffer.from(pdfBytes)
  });

  if (!uploadResponse.ok) {
    throw new Error(`LinkedIn upload failed: ${uploadResponse.status}`);
  }

  return value.document;
}

Architecture Rationale: The API expects raw binary streaming for the PUT request. The returned document URN is later attached to the UGC share payload under the media field. LinkedIn enforces a 100MB and 300-page limit, but typical carousels stay well under 5MB and 15 pages.

Pitfall Guide

1. Ignoring @page CSS Rules

Explanation: Developers often set dimensions via Puppeteer's format option or viewport scaling, which causes inconsistent rendering across different Chromium versions. Fix: Always declare @page { size: 1080px 1350px; margin: 0; } in the HTML template and enable preferCSSPageSize: true in Puppeteer.

2. Missing Background Rendering Flag

Explanation: Puppeteer disables background painting by default to save ink during print previews. This results in white slides with missing gradients or color blocks. Fix: Set printBackground: true in the PDF generation options.

3. Font Loading Race Conditions

Explanation: If web fonts load asynchronously, Puppeteer may capture the page before text reflows, causing layout shifts or fallback font rendering. Fix: Use waitUntil: "networkidle0", preload fonts via <link rel="preload">, or embed base64 fonts directly in the template for critical paths.

4. Monolithic Multi-Page Rendering

Explanation: Attempting to render all slides in a single browser context causes memory fragmentation, CSS leakage, and unpredictable pagination breaks. Fix: Render each slide in an isolated page instance, then merge buffers using pdf-lib. This keeps memory usage predictable and prevents cross-slide contamination.

5. Serverless Bundle Bloat

Explanation: Standard Puppeteer ships with a full Chromium binary (~170MB), exceeding Vercel's 50MB serverless function limit and AWS Lambda's deployment package constraints. Fix: Use puppeteer-core paired with @sparticuz/chromium for serverless environments. This reduces the footprint to under 30MB while maintaining full rendering capabilities.

6. Skipping Upload Status Verification

Explanation: LinkedIn's document upload is asynchronous. Assuming immediate availability can cause share creation failures if the document hasn't finished processing. Fix: Poll the document status endpoint or implement exponential backoff before attaching the URN to a share payload.

7. Hardcoding Viewport Scaling

Explanation: Relying on page.setViewport() without matching CSS dimensions causes double-scaling artifacts and blurry text. Fix: Let CSS dictate dimensions via @page. Remove viewport overrides unless testing responsive layouts.

Production Bundle

Action Checklist

  • Define slide dimensions in CSS @page rule with zero margins
  • Enable preferCSSPageSize and printBackground in Puppeteer options
  • Implement per-page rendering with isolated browser contexts
  • Merge PDF buffers using pdf-lib to preserve vector quality
  • Configure puppeteer-core with @sparticuz/chromium for serverless deployment
  • Implement LinkedIn document upload handshake with error handling
  • Add upload status polling before share creation
  • Cache rendered PDFs for identical content to reduce cold starts

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Low traffic, predictable load Serverless (Vercel/AWS) with @sparticuz/chromium Pay-per-use, scales automatically, minimal ops overhead $0.20-$0.50 per 1k renders
High traffic, bursty patterns Dedicated container (Fly.io/Render) Persistent browser instances eliminate cold starts, better memory management $15-$40/month fixed + compute
Enterprise compliance, zero infra Managed API (Browserless/PDFShift) Vendor handles Chromium updates, scaling, and security patches $0.05-$0.15 per render
Internal tooling, low volume Local Puppeteer with full Chromium Simplest setup for development and testing Free (dev environment)

Configuration Template

// carousel-pipeline.ts
import puppeteer from "puppeteer-core";
import chromium from "@sparticuz/chromium";
import { PDFDocument } from "pdf-lib";

export class CarouselPipeline {
  private browser: ReturnType<typeof puppeteer.launch> | null = null;

  async initialize(): Promise<void> {
    this.browser = await puppeteer.launch({
      args: chromium.args,
      executablePath: await chromium.executablePath(),
      headless: chromium.headless,
      ignoreDefaultArgs: ["--disable-extensions"]
    });
  }

  async generate(slides: Array<{ heading: string; content: string; accent: string }>): Promise<Uint8Array> {
    if (!this.browser) throw new Error("Pipeline not initialized");

    const buffers: Buffer[] = [];
    const total = slides.length;

    for (let i = 0; i < total; i++) {
      const page = await this.browser.newPage();
      const markup = this.buildMarkup({ pageIndex: i, totalPages: total, ...slides[i] });
      
      await page.setContent(markup, { waitUntil: "networkidle0" });
      const pdf = await page.pdf({
        width: "1080px",
        height: "1350px",
        printBackground: true,
        preferCSSPageSize: true,
        margin: { top: "0", right: "0", bottom: "0", left: "0" }
      });
      buffers.push(Buffer.from(pdf));
      await page.close();
    }

    return this.merge(buffers);
  }

  private buildMarkup(props: any): string {
    // Insert template generation logic here
    return `<!DOCTYPE html>...`;
  }

  private async merge(buffers: Buffer[]): Promise<Uint8Array> {
    const doc = await PDFDocument.create();
    for (const buf of buffers) {
      const src = await PDFDocument.load(buf);
      const [page] = await doc.copyPages(src, [0]);
      doc.addPage(page);
    }
    return doc.save();
  }

  async shutdown(): Promise<void> {
    if (this.browser) await this.browser.close();
  }
}

Quick Start Guide

  1. Install dependencies: npm install puppeteer-core @sparticuz/chromium pdf-lib
  2. Initialize the pipeline: Create a CarouselPipeline instance and call initialize() before processing requests.
  3. Generate a carousel: Pass an array of slide objects to generate(). The method returns a Uint8Array containing the merged PDF.
  4. Upload to LinkedIn: Send the PDF bytes through the three-step document upload flow. Capture the returned URN.
  5. Create the share: Attach the document URN to a UGC share payload and POST to LinkedIn's share endpoint. Verify the carousel appears as swipeable slides in the feed.