Rendering Diagrams with AWS Lambda

Matt MartzMatt Martz
9 min read

Diagrams are the unsung heroes of technical projects. Whether you’re sketching out the architecture of your latest application or untangling the web of nodes in your LLM agents, a good visual can make all the difference. But what if you could dynamically render those diagrams on the fly, straight from Mermaid or Draw.io formats, and store them in S3 for easy access? That’s exactly what we’ll explore today.

In this post, I’ll show you how I built a serverless solution using AWS Lambda, Puppeteer, and CDK to do just that. The solution consists of two Lambda functions working in tandem to render PNG images and serve them up via pre-signed S3 URLs. Along the way, we’ll talk about the infrastructure, the code, and some of the lessons learned.

The code for this project is available on GitHub.

The Problem and The Solution

Let’s set the stage. Imagine you’re working on a project that needs to dynamically render diagrams—whether from Mermaid markdown or Draw.io XML. Once rendered, these diagrams must be stored securely as PNG images in S3 for easy sharing and integration, with access provided via pre-signed URLs.

Sounds simple enough, but if you’ve ever tried rendering diagrams programmatically, you know it’s a bit like herding cats—except these cats are diagrams, and they bite. That’s where the power of AWS Lambda, Puppeteer, and the AWS CDK comes in. Together, they tame the chaos and streamline the process.

Here’s how the solution works:

  1. Mermaid Renderer Lambda: Accepts Mermaid markdown as input, uses Puppeteer to generate the PNG, and uploads the result to S3.

  2. Draw.io Renderer Lambda: Does the same, but for Draw.io XML files.

  3. S3 Bucket with Pre-Signed URLs: A secure storage and sharing mechanism for the rendered diagrams.

  4. Infrastructure with CDK: Custom constructs tie everything together in a clean and reusable way.

Why Puppeteer?

Puppeteer, a Node.js library, provides a high-level API to control Chromium or Chrome browsers, making it perfect for rendering web-based content like Mermaid and Draw.io diagrams. However, running Puppeteer in Lambda isn’t plug-and-play. Lambda’s execution environment requires headless browser support, which can be tricky to set up.

To solve this, I used @sparticuz/chromium-min, a prebuilt Chromium layer optimized for AWS Lambda. It ensures that Puppeteer runs seamlessly within Lambda's constraints, handling the rendering process efficiently and reliably.

Infrastructure: Laying the Groundwork with CDK and Custom Constructs

To power our diagram-rendering solution, we need a robust and flexible infrastructure. Enter AWS CDK, with a sprinkle of customization courtesy of my @martzmakes/constructs library—a set of open-source constructs designed to simplify and enhance AWS projects. Let’s break down the infrastructure code for this project.

Buckets, Lambdas, and Puppeteer, Oh My!

The heart of the infrastructure is the LambdaDiagramStack class. It uses CDK constructs and custom opinions from @martzmakes/constructs to define two Lambda functions (Mermaid and Draw.io) for the rendering work.

Here’s the full code for the stack:

export class LambdaDiagramStack extends MMStack {
  constructor(scope: Construct, id: string, props: MMStackProps) {
    super(scope, id, props);
    const diagramBucket = new Bucket(this, "DiagramBucket", {
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      eventBridgeEnabled: true,
      objectOwnership: ObjectOwnership.BUCKET_OWNER_ENFORCED,
      lifecycleRules: [
        {
          expiration: Duration.days(1),
          enabled: true,
        },
      ],
    });

    new Lambda(this, "MermaidLambda", {
      entry: join(__dirname, `./fns/mermaid.ts`),
      eventPattern: {
        source: [this.eventSource],
        detailType: ["mermaid"],
      },
      name: "mermaid",
      architecture: Architecture.X86_64, // puppeteer needs x86_64
      bundling: {
        externalModules: [],
      },
      memorySize: 10240,
      buckets: {
        BUCKET_NAME: { bucket: diagramBucket, access: "rw" },
      },
    });

    new Lambda(this, "DrawIOLambda", {
      entry: join(__dirname, `./fns/drawio.ts`),
      eventPattern: {
        source: [this.eventSource],
        detailType: ["drawio"],
      },
      bundling: {
        externalModules: [],
      },
      name: "drawio",
      architecture: Architecture.X86_64, // puppeteer needs x86_64
      memorySize: 10240,
      buckets: {
        BUCKET_NAME: { bucket: diagramBucket, access: "rw" },
      },
    });
  }
}

Key Components

  1. The S3 Bucket:

    • Block Public Access: Ensures the bucket is private and secure.

    • Lifecycle Rules: Automatically cleans up old files after one day, keeping things tidy.

    • Ownership Enforced: Simplifies managing access policies.

  2. Custom Lambda Constructs:

    • MermaidLambda and DrawIOLambda are created using the Lambda construct from @martzmakes/constructs.

    • Both Lambdas are configured with:

      • X86_64 architecture: Puppeteer requires this architecture to work correctly.

      • Generous memory allocation: 10 GB ensures Puppeteer runs smoothly.

      • Bucket access: Enables each Lambda to read from and write to the S3 bucket.

  3. CDK Opinions:

    • The @martzmakes/constructs library simplifies common patterns while enabling observability. For instance, the Lambda construct extends CDK’s NodejsFunction to handle bundling, memory configuration, and event patterns.

Puppeteer and the X86_64 Architecture

A critical note for Puppeteer users: it won’t run on ARM-based architectures like Graviton. By explicitly setting the Lambdas to use Architecture.X86_64, we sidestep potential compatibility issues. While this may sacrifice some cost or performance benefits of ARM, it ensures Puppeteer operates reliably.

Lambda Functions: Rendering Diagrams with Puppeteer and S3 Integration

Now that we’ve set up the infrastructure, let’s dive into how the Lambda functions actually work. Both the Mermaid and Draw.io rendering Lambdas follow a similar pattern, leveraging Puppeteer for rendering and S3 for storing the output. They’re wrapped with helper methods from @martzmakes/constructs, ensuring observability and seamless integration.

Common Patterns in Both Lambdas

  1. Initialization with initEventHandler: Each Lambda uses initEventHandler from @martzmakes/constructs to:

    • Set up AWS X-Ray tracing for better observability using Lambda Powertools.

    • Emit an architecture event to infer system architecture for future observability enhancements.

  2. Chromium Setup with @sparticuz/chromium-min: The @sparticuz/chromium-min library provides a prebuilt Chromium binary tailored for AWS Lambda. It’s essential for rendering web-based diagrams within Puppeteer.

  3. S3 Upload with Pre-Signed URL: The rendered diagrams are uploaded to S3 using uploadImageToS3AndGetPresignedUrl from @martzmakes/constructs. This helper function simplifies the process of generating a pre-signed URL after uploading an image.

Mermaid Lambda

Here’s how the Mermaid Lambda works:

  • Input: Takes a title and a mermaidCode (the diagram definition in Mermaid markdown).

  • Process:

    1. Generates an HTML template embedding the Mermaid diagram.

    2. Launches Puppeteer using the prebuilt Chromium binary.

    3. Sets the HTML content and waits for the diagram to render.

    4. Captures the SVG element as a PNG image buffer.

  • Output: Uploads the buffer to S3 and returns a pre-signed URL.

Here’s the core implementation:

const mermaidToImageBuffer = async ({
  title,
  mermaidCode,
}: {
  title: string;
  mermaidCode: string;
}): Promise<Buffer> => {
  // HTML template to render the Mermaid diagram
  const htmlTemplate = `
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <title>${title}</title>
          <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
          <style>
              body {
                  margin: 0;
                  display: flex;
                  justify-content: center;
                  align-items: center;
                  height: 100vh;
                  background: white;
              }
          </style>
      </head>
      <body>
          <div id="mermaidContainer">
              <div class="mermaid">${mermaidCode}</div>
          </div>
          <script>
              mermaid.initialize({ startOnLoad: true });
          </script>
      </body>
      </html>
  `;

  // Launch Puppeteer
  chromium.setHeadlessMode = true;
  chromium.setGraphicsMode = true;
  const browser = await puppeteer.launch({
    args: [
      ...chromium.args,
    ],
    defaultViewport: {
      width: 1920,
      height: 1080,
      deviceScaleFactor: 3,
    },
    executablePath: await chromium.executablePath(
      "https://github.com/Sparticuz/chromium/releases/download/v119.0.2/chromium-v119.0.2-pack.tar"
    ),
    headless: false,
  });
  const page = await browser.newPage();

  // Set content
  await page.setContent(htmlTemplate, { waitUntil: "networkidle0" });

  // Wait for the diagram to render
  await page.waitForSelector(".mermaid > svg");

  // Select the SVG element and capture as an image
  const element = await page.$(".mermaid > svg");
  if (!element) {
    throw new Error("Failed to render Mermaid diagram");
  }

  // Scale bounding box dimensions by the deviceScaleFactor
  const buffer = await element.screenshot({
    type: "png",
  });

  await browser.close();
  return Buffer.from(buffer);
};

const eventHandler: EventHandler<{
  title: string;
  mermaidCode: string;
}> = async ({ data }) => {
  const { title, mermaidCode } = data;
  const buffer = await mermaidToImageBuffer({
    title,
    mermaidCode,
  });

  const key = `${Date.now()}-${title}.png`;
  const presignedUrl = await uploadImageToS3AndGetPresignedUrl({ key, buffer });
  console.log(JSON.stringify({ presignedUrl }));
};

export const handler = initEventHandler({ eventHandler });

Draw.io Lambda

The Draw.io Lambda follows a similar flow but uses a Draw.io-specific rendering process:

  • Input: Takes a title and a drawioXml (the diagram definition in Draw.io XML).

  • Process:

    1. Navigates Puppeteer to the Draw.io export URL.

    2. Uses page scripts to render the Draw.io XML into an SVG.

    3. Captures the SVG as a PNG image buffer.

  • Output: Same as Mermaid Lambda—uploads to S3 and generates a pre-signed URL.

Here’s the core implementation:

const drawioToImageBuffer = async ({
  drawioXml,
}: {
  drawioXml: string;
}): Promise<Buffer> => {
  // Launch Puppeteer
  chromium.setHeadlessMode = true;
  chromium.setGraphicsMode = true;
  const width = 2 * 1920;
  const height = 2 * 1080;
  const browser = await puppeteer.launch({
    args: [...chromium.args],
    defaultViewport: {
      width,
      height,
      deviceScaleFactor: 3,
    },
    executablePath: await chromium.executablePath(
      "https://github.com/Sparticuz/chromium/releases/download/v119.0.2/chromium-v119.0.2-pack.tar"
    ),
    headless: false,
  });
  const page = await browser.newPage();
  await page.goto("https://www.draw.io/export3.html", {
    waitUntil: "networkidle0",
  });

  await page.evaluate(
    (obj) => {
      return (window as any).render({
        h: obj.height,
        w: obj.width,
        xml: obj.drawioXml,
      });
    },
    { drawioXml, width, height }
  );
  await page.waitForSelector("#LoadingComplete");
  console.log("Loading complete");

  // Select the SVG element and capture as an image
  const element = await page.$("#graph > svg");
  if (!element) {
    throw new Error("Failed to render Mermaid diagram");
  }

  // Scale bounding box dimensions by the deviceScaleFactor
  const buffer = await element.screenshot({
    type: "png",
    fullPage: true,
  });

  await browser.close();
  return Buffer.from(buffer);
};

const eventHandler: EventHandler<{
  title: string;
  drawioXml: string;
}> = async ({ data }) => {
  const { title, drawioXml } = data;
  const buffer = await drawioToImageBuffer({
    drawioXml,
  });

  const key = `${Date.now()}-${title}.png`;
  const presignedUrl = await uploadImageToS3AndGetPresignedUrl({ key, buffer });
  console.log(JSON.stringify({ presignedUrl }));
};

export const handler = initEventHandler({ eventHandler });

Observability and Future Enhancements

Both Lambdas emit architecture events for observability—a feature I’ll explore in a future post. This ensures we can visualize architecture changes and interactions using the rendered diagrams. For now, the event data helps lay the groundwork for a deeper understanding of your system.

Putting It All Together: Seamless Serverless Diagram Rendering

Here’s how the system operates end-to-end:

  1. Trigger:

    • A client sends an event to the EventBridge source configured for either the Mermaid or Draw.io Lambda.

    • The event payload includes the diagram’s title and its respective code or XML.

  2. Processing:

    • The appropriate Lambda function is triggered based on the event type (mermaid or drawio).

    • The Lambda:

      • Processes the input using Puppeteer to render the diagram.

      • Uploads the generated PNG to the S3 bucket.

      • Retrieves a pre-signed URL for accessing the image.

  3. Response:

    • The Lambda returns the pre-signed URL to the client, making the image available for secure, time-bound access.
  4. Lifecycle Management:

    • A lifecycle policy on the S3 bucket ensures that old diagrams are automatically deleted after one day, maintaining a clean and cost-effective storage environment.

By leveraging @martzmakes/constructs, the system can provide insights:

  • Tracing with Lambda Powertools:

    • End-to-end tracing via AWS X-Ray gives a detailed view of the rendering process.

    • Helps identify bottlenecks, such as rendering delays or S3 upload issues.

  • Architecture Events:

    • Emitted by the initEventHandler, these events are invaluable for mapping how diagrams relate to system architecture.

    • In future posts, we’ll use this data to visualize system interactions and changes.

What’s Next?

In future posts, I’ll show you how to use this solution for:

  • Visualizing advanced multi-node LLM agents and their decisions using LangGraph and LangChain with Mermaid.

  • Observing architectural changes in your projects with Draw.io.

  • Generating visuals of upstream/downstream error propagation and sending them to Slack.

Stay tuned for those deep dives, but in the meantime, feel free to tinker with the code, experiment with new diagramming formats, or even extend the solution for other use cases.

Serverless rendering of diagrams might not save the world, but it can definitely save your sanity when managing complex systems. By combining the power of AWS Lambda, Puppeteer, and S3, we’ve created a flexible and scalable solution for rendering and sharing diagrams.

What’s the most creative way you can think of to use this? Let me know in the comments!

1
Subscribe to my newsletter

Read articles from Matt Martz directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Matt Martz
Matt Martz

I'm an AWS Community Builder and a Principal Software Architect I love all things CDK, Event Driven Architecture and Serverless