Ansomail is an AI-powered drag-and-drop email builder I’m building in public. This week, I replaced its JavaScript rendering engine with Rust — and made it 15x faster.
I decided to do something scary: build a SaaS product in public, from scratch, sharing every win and every embarrassing error log along the way.
By Day 4, I had:
- Crashed my database
- Broken hot reload
- Built an engine that felt painfully slow
By Day 7, I had:
- Rewritten the rendering engine in Rust
- Reduced render time from ~18ms → ~1.1ms (~15x faster)
- Learned that “bleeding edge” stacks come with hidden costs
This is my Week 1 retrospective building Ansomail, an AI-powered drag-and-drop email editor.
The Goal: Building a High-Performance AI Email Builder
I’m building an email editor where:
- Developers define the design system
- Marketers drag-and-drop blocks
- AI generates content — but never breaks layout
The constraint: The preview must feel like Google Docs. Instant.
Tech Stack: Next.js 16, Bun, Postgres & MJML
I deliberately chose a modern stack optimized for speed:
- Runtime: Bun
- Framework: Next.js 16 (Turbopack)
- Monorepo: Turborepo
- Database: Postgres + Drizzle ORM
- Linting/Formatting: Biome
-
Initial Rendering Engine:
mjml-browser
It looked perfect on paper.
Then reality happened.
Days 1–2: Enforcing AI Output with JSON Schema Validation
The first architectural decision wasn’t about performance.
It was about control.
Instead of letting the LLM freely generate HTML, I:
- Forced output into a strict JSON Schema
- Validated structure before rendering
- Rejected hallucinated classes or invalid styles
If the AI generates something outside the system, the update is rejected before it ever touches the UI.
That constraint will save me months later.
Days 3–4: The “Bleeding Edge” Tax (Next.js + Bun Issues)
Using Bun with Next.js 16 is fast — but not always stable.
Hot reload started crashing with cache component errors:
Next.js cannot guarantee that Cache Components will run as expected due to the current runtime's implementation of setTimeout().
Then I hit this:
FATAL: sorry, too many clients already
In a serverless dev environment, every hot reload opened a new Postgres connection.
They weren’t being closed. My database choked.
Fixing “Too Many Clients Already” with Proper Connection Pooling
“Serverless” does NOT mean “Ops-less.”
I implemented strict pooling and ensured a singleton pattern during development:
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is missing");
}
const globalForDb = globalThis as unknown as {
pool: Pool | undefined;
};
const pool =
globalForDb.pool ??
new Pool({
connectionString,
max: process.env.DB_MAX_CONNECTIONS
? parseInt(process.env.DB_MAX_CONNECTIONS, 10)
: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
if (process.env.NODE_ENV !== "production") {
globalForDb.pool = pool;
pool.on("connect", (client) => {
console.log(`🔌 Database: New client connected to the pool; client: ${client}`);
});
pool.on("error", (err, client) => {
console.error(`❌ Database: Unexpected error on idle client; client: ${client}`, err);
});
pool.on("remove", (client) => {
console.log(`🗑️ Database: Client removed from pool; client: ${client}`);
});
}
export const db = drizzle(pool, { schema });
Once I capped connections, everything stabilized.
Lesson: Dev hot reload + serverless Postgres can quietly DOS your own app.
Days 5–6: Implementing Optimistic UI for Instant Feedback
I implemented Optimistic UI for renaming templates.
User types → UI updates immediately.
Server validates in the background.
Failure → rollback.
Success → seamless.
That part worked beautifully.
But the real bottleneck was rendering.
Initial Rendering Pipeline (JavaScript + MJML)
The original rendering pipeline:
- JSON stored in DB
- JSON → MJML
-
mjml-browserconverts MJML → HTML - HTML rendered inside an iframe
It worked.
But it averaged ~18ms per render.
For a drag-and-drop email builder, that felt sluggish.
Day 7: Replacing JavaScript with Rust + WebAssembly
The bottleneck was mjml-browser (pure JavaScript).
So I replaced it.
I integrated MRML (a Rust port of MJML) compiled to WebAssembly (WASM).
Why?
- Rust → predictable performance
- MRML → faster MJML parsing
- WASM → near-native speed in the browser
Rust Integration Challenges
Rust does not tolerate “almost correct.”
It refused to compile my “sloppy” JSON that JavaScript had been happily ignoring.
It forced me to fix my data structures.
That strictness improved my architecture.
Performance Results: 15x Faster Email Rendering
After integrating MRML via WASM:
- JavaScript Engine: ~18ms
- Rust (WASM) Engine: ~1.1ms
That’s roughly a 15x performance improvement.
The preview now runs comfortably within a 16ms frame budget — effectively real-time at 60fps.
Unexpected Benefits of Moving to Rust
- Cleaner schema enforcement
- Deterministic parsing
- Smaller rendering bottleneck surface
- Future-proof performance foundation
Lessons Learned Building a SaaS in Public (Week 1)
- Bleeding-edge stacks save time — until they don’t.
- Serverless still requires backend discipline.
- Rust’s strictness is a feature, not friction.
- Early performance decisions compound.
What’s Next for Ansomail?
Week 1 was about the foundation.
Week 2 is about interaction.
Now that I have a ~1ms rendering engine, I’m building the actual drag-and-drop layer on top of it.
I’m documenting this entire journey publicly.
If you're building with Next.js, Rust, WebAssembly, or experimenting with high-performance SaaS architecture — I’d love to connect.
Ansomail is currently in development. If you're a developer or marketer who struggles with email design systems, I’d love to talk.
Top comments (0)