Rendering Diagrams with AWS Lambda


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:
Mermaid Renderer Lambda: Accepts Mermaid markdown as input, uses Puppeteer to generate the PNG, and uploads the result to S3.
Draw.io Renderer Lambda: Does the same, but for Draw.io XML files.
S3 Bucket with Pre-Signed URLs: A secure storage and sharing mechanism for the rendered diagrams.
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
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.
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.
CDK Opinions:
- The @martzmakes/constructs library simplifies common patterns while enabling observability. For instance, the
Lambda
construct extends CDK’sNodejsFunction
to handle bundling, memory configuration, and event patterns.
- The @martzmakes/constructs library simplifies common patterns while enabling observability. For instance, the
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
Initialization with
initEventHandler
: Each Lambda usesinitEventHandler
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.
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.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 amermaidCode
(the diagram definition in Mermaid markdown).Process:
Generates an HTML template embedding the Mermaid diagram.
Launches Puppeteer using the prebuilt Chromium binary.
Sets the HTML content and waits for the diagram to render.
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 adrawioXml
(the diagram definition in Draw.io XML).Process:
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:
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.
Processing:
The appropriate Lambda function is triggered based on the event type (
mermaid
ordrawio
).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.
Response:
- The Lambda returns the pre-signed URL to the client, making the image available for secure, time-bound access.
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!
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