Shipping Labels and Barcodes in Generated PDFs

A practical guide to generating shipping labels with scannable barcodes and QR codes in PDFs, covering symbologies, DPI, layout, and carrier requirements.

pdfbarcodesshippinglogisticsnode.js

Shipping Labels and Barcodes in Generated PDFs

Shipping labels look simple until you print one and the scanner refuses to read it. Between carrier-specific formats, barcode symbologies, DPI requirements, and thermal printer quirks, generating labels that actually work in the real world takes more care than most PDF tutorials suggest. This post walks through the concrete decisions you need to make when generating shipping labels programmatically.

What a shipping label actually needs

A functional shipping label has four things:

  1. A routing barcode (usually Code 128 or a carrier-specific format like PDF417 for USPS IMpb).
  2. Human-readable addressing for both sender and recipient.
  3. A tracking number rendered as both text and barcode.
  4. Service metadata — weight, class, date, reference numbers.

Most carriers publish a spec sheet. USPS wants IMpb (Intelligent Mail Package Barcode) using GS1-128 encoding. UPS uses MaxiCode plus Code 128. FedEx uses Code 128C for the tracking number. Ignore these specs and your labels will be rejected at pickup.

Picking the right barcode symbology

For most in-house use (warehouse pick lists, internal routing), Code 128 is the safe default. It handles ASCII, is compact, and every scanner supports it. For QR-style 2D data, use QR Code or DataMatrix. Carrier labels are the exception — use exactly what the carrier requires.

Here's a quick reference:

txt
Code 128 — general purpose, alphanumeric, high density Code 39 — legacy, uppercase only, avoid for new work GS1-128 — Code 128 with application identifiers (carriers, GS1 SSCC) PDF417 — 2D stacked, required by USPS IMpb QR Code — 2D, high error correction, good for URLs and tracking DataMatrix — 2D, very compact, common in pharma and small items MaxiCode — 2D, UPS-specific

DPI and physical size matter

A barcode that looks fine on screen can be unscannable when printed. The critical measurement is the X-dimension — the width of the narrowest bar. For thermal printers at 203 DPI (the standard for 4x6 shipping labels), your X-dimension should be at least 2 pixels wide, which is roughly 0.25mm.

When generating a PDF for a 4x6 label:

  • Target page size: 288 x 432 points (4in x 6in at 72 DPI).
  • Render barcodes at 300+ DPI as embedded raster images, or use vector output if your library supports it.
  • Leave a quiet zone of at least 10x the X-dimension on both sides of 1D barcodes.

Generating a label in Node

Here's a minimal example using bwip-js to produce the barcode image and pdf-lib to place it:

ts
import { PDFDocument, StandardFonts } from "pdf-lib"; import bwipjs from "bwip-js"; import fs from "node:fs/promises"; async function buildLabel(tracking: string, to: string[]) { const pdf = await PDFDocument.create(); const page = pdf.addPage([288, 432]); const font = await pdf.embedFont(StandardFonts.HelveticaBold); const barcodePng = await bwipjs.toBuffer({ bcid: "code128", text: tracking, scale: 3, height: 20, includetext: false, }); const img = await pdf.embedPng(barcodePng); page.drawImage(img, { x: 20, y: 60, width: 248, height: 80 }); page.drawText(tracking, { x: 20, y: 40, size: 14, font }); to.forEach((line, i) => { page.drawText(line, { x: 20, y: 300 - i * 16, size: 11, font }); }); await fs.writeFile("label.pdf", await pdf.save()); } await buildLabel("1Z999AA10123456784", [ "ACME Warehouse", "123 Loading Dock Rd", "Portland, OR 97201", ]);

This works for internal labels. For carrier-compliant labels, you'd swap code128 for the required symbology and follow the exact positioning in the carrier spec.

QR codes for tracking URLs

If you want customers to scan a package and land on a tracking page, add a QR code:

ts
const qrPng = await bwipjs.toBuffer({ bcid: "qrcode", text: `https://track.example.com/${tracking}`, scale: 4, eclevel: "M", }); const qr = await pdf.embedPng(qrPng); page.drawImage(qr, { x: 200, y: 340, width: 70, height: 70 });

Use error correction level M or Q. Higher correction means the code still scans after some scuffing or tape covers a corner.

Doing this at scale

Generating one label is easy. Generating 50,000 a day is different. A few things bite in production:

  • Font embedding duplicated across every PDF wastes bandwidth. Subset and reuse.
  • Barcode rasterization in Node under load will pin a CPU. Consider a worker pool or offload it.
  • Batch merging — most warehouses want a single PDF of all labels for a shift, not 500 files.

If you'd rather not run this infrastructure, Kamy handles label templates and barcode rendering server-side. You define an HTML/CSS template with a barcode component and post the data:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render