Supabase Edge Functions

Generating PDFs from Supabase Edge Functions

Edge Functions give you 256 MB of memory, 2 seconds of CPU and a 20 MB bundle. Chromium is about 300 MB. That is the entire problem — and the reason the fix is not a smaller browser but moving the render off the function.

The limits you are working inside

Supabase Edge Function runtime limits and their consequences for PDF generation
LimitValueWhat it means here
Memory256 MBA headless Chromium process alone typically wants more than this to render one page.
CPU time2 seconds per requestLaunching a browser burns this before it paints anything. Crucially, async I/O does not count — awaiting a fetch is free.
Wall clock150s free / 400s paidGenerous. The wall clock is never what stops you; CPU and memory are.
Function size20 MB via the CLI, 5 MB server-bundledChromium is roughly 300 MB. It is not close.

Figures from Supabase's published Edge Function limits. Verify against their docs before designing around them.

The load-bearing detail

The 2-second CPU limit excludes async I/O. A function that awaits an external render is idle while it waits, so it never approaches the budget — you have up to 150s (free) or 400s (paid) of wall clock to play with. A function that renders the document itself is spending real CPU on layout, font shaping and rasterisation, and 2 seconds goes quickly.

Your three real options

1. Draw it by hand with pdf-lib or jsPDF in Deno

Works, no external dependency, no per-render cost. You get no CSS layout engine, so every element is positioned by coordinate and pagination is arithmetic you write. Reasonable for a fixed one-page receipt; painful for anything a designer touches.

2. Connect to a remote browser over WebSocket

This is what Supabase's own screenshot example does — the browser lives elsewhere and the function drives it. Full CSS, and it can capture live URLs. You are now paying for and operating a browser service, and handling session exhaustion and timeouts.

3. Call a render API

One fetch, one binary back. Full Chromium CSS on the PDF path, no session to manage, and the CPU cost stays off your function. The trade-off is honest: it is a network dependency and a per-render cost, and it cannot capture a live page the way a browser can.

Complete example: render, store, sign

The pattern most invoice and certificate flows want — the PDF lands in Storage and the caller gets a time-limited URL. Set the key with supabase secrets set RENDERFY_API_KEY=rfy_live_...

// supabase/functions/generate-invoice/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

Deno.serve(async (req) => {
  const { invoiceId } = await req.json();

  const { data: invoice, error } = await supabase
    .from("invoices")
    .select("*")
    .eq("id", invoiceId)
    .single();

  if (error) {
    return Response.json({ error: error.message }, { status: 404 });
  }

  // Awaiting this is I/O — it does not touch the 2s CPU budget.
  const render = await fetch("https://renderfy.io/api/v1/render", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("RENDERFY_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "html",
      content: invoiceHtml(invoice),
      options: { format: "pdf", dimensions: "a4" },
    }),
  });

  if (!render.ok) {
    return Response.json({ error: "Render failed" }, { status: 502 });
  }

  const pdf = await render.arrayBuffer();
  const path = `invoices/${invoice.id}.pdf`;

  const { error: uploadError } = await supabase.storage
    .from("documents")
    .upload(path, pdf, {
      contentType: "application/pdf",
      upsert: true,
    });

  if (uploadError) {
    return Response.json({ error: uploadError.message }, { status: 500 });
  }

  const { data: signed } = await supabase.storage
    .from("documents")
    .createSignedUrl(path, 60 * 60); // 1 hour

  return Response.json({ url: signed?.signedUrl });
});

Deploy with supabase functions deploy generate-invoice. The function bundle stays a few kilobytes, because nothing heavy ships with it.

Two things to get right

  • Use the service role key only inside the function. It bypasses row-level security. The client should receive the signed URL, never the key.
  • Make retries safe. If the caller retries after a network blip you do not want to be charged twice or write a second object. Send an Idempotency-Key derived from the invoice id, and keep upsert: true on the Storage write.

Frequently asked questions

Can I run Puppeteer in a Supabase Edge Function?+

Not locally. Edge Functions run Deno with a 256 MB memory ceiling, 2 seconds of CPU time per request and a maximum function size of 20 MB when bundled by the CLI. Chromium is roughly 300 MB and needs far more than 256 MB to render. Supabase's own screenshot example does not launch a browser inside the function — it connects over a WebSocket to a browser hosted somewhere else.

What does the 2 second CPU limit actually cover?+

Only CPU time, not async I/O. Time spent awaiting a network request does not count against it. This is the single most useful fact for this problem: a function that awaits an external render stays comfortably inside the budget, while a function that lays out and rasterises a document itself will not.

What about pdf-lib or jsPDF in Deno?+

They run, and for fixed coordinate-drawn layouts they are a reasonable choice. But neither has a CSS layout engine — you position every element yourself. The moment the document has variable-length content, a table that must paginate, or a design your team maintains in HTML, you are hand-writing a layout engine inside a 2-second CPU budget.

Should I use a database trigger or call the function directly?+

Both work. A common pattern is to invoke the Edge Function from your application after the business event (an order is paid, a certificate is earned), render the PDF, upload it to Supabase Storage, and store the object path on the row. Doing it from a Postgres trigger via pg_net is possible but makes failures harder to retry — prefer an explicit call, or a queue, when the document matters.

How do I return the PDF to the client?+

For small documents, return the bytes directly from the function with a Content-Type of application/pdf. For anything you want to keep, upload to Supabase Storage from inside the function and return a signed URL. The second pattern is better for invoices and receipts, because the document remains retrievable and access stays governed by your Storage policies.

Does Renderfy work with the Deno runtime?+

Rendering is a single HTTPS POST with a JSON body, so plain fetch works in Deno with no package at all — that is what the example below uses, and it is the approach we recommend here. The @renderfy/sdk package lists Node.js 18+, Next.js Edge, Cloudflare Workers and Bun as supported runtimes; Deno is not on that list, so do not assume the npm: specifier route is supported.