Edge Data Modeling & Offline Resiliency: Cloudflare D1, Batch Transactions, and Media Pipelines
In Part 1 of this series, I detailed how we decoupled the Nabodigonto platform into a Turborepo monorepo with dedicated edge services.
Once the service boundaries were established, the next major engineering challenge was data persistence and document generation at the serverless edge. Non-profit operations require rock-solid relational consistency: monthly dues must match financial ledgers, member statuses must be updated atomically, and members need printable identity cards and donation receipts in Bengali.
Here is how we solved relational edge data modeling on Cloudflare D1 and built an in-memory Bengali vector PDF generation pipeline on Cloudflare Workers and R2.
1. Choosing Cloudflare D1: SQLite at the Global Edge
Traditional web apps connect to a centralized PostgreSQL or MySQL instance through persistent connection pools (like PgBouncer). In serverless edge environments (Cloudflare Workers, V8 isolates), maintaining long-lived TCP connection pools to a single origin database introduces significant latency and connection exhaustion risks.
We chose Cloudflare D1, which provides distributed SQLite at the edge:
- Zero Connection Management: Worker isolates execute queries via internal RPC bindings directly against the D1 storage engine.
- Microsecond Read Latency: Read replicas can cache query results close to end users in South Asia.
- Type-Safe Schema with Prisma: We use Prisma ORM coupled with the
@prisma/adapter-d1driver adapter inside our sharedpackages/dbpackage.
1// packages/db/prisma/schema.prisma23datasource db {4 provider = "sqlite"5 url = "file:./dev.db"6}78generator client {9 provider = "prisma-client-js"10 previewFeatures = ["driverAdapters"]11}1213model Member {14 id String @id @default(cuid())15 memberNumber String @unique16 fullName String17 bloodGroup String18 phoneNumber String @unique19 role String @default("GENERAL_MEMBER")20 status String @default("ACTIVE")21 createdAt DateTime @default(now())22 updatedAt DateTime @updatedAt23 24 duesPayments DuesPayment[]25 auditEntries AuditLog[]26}2728model DuesPayment {2. Edge SQLite Constraints: Atomic Batch Transactions
While SQLite is incredibly fast for reads, it has a distinct operational constraint in distributed systems: writes are strictly serialized. If an edge API makes multiple round-trip queries over the network to complete a single business transaction, each statement incurs coordinator overhead and lock contention.
Consider what happens when recording a member dues payment:
- Verify the member is active.
- Insert a new
DuesPaymentrecord with a generated receipt number. - Update the member's last-paid dues period.
- Record an audit log entry for the receiving officer.
If executed as sequential await calls, an unexpected error or worker timeout halfway through would leave the database in an inconsistent state (e.g. payment logged, but member status not updated).
To prevent this, we execute multi-step mutations using atomic batch statements:
1// packages/db/src/services/dues.ts2import { D1Database } from "@cloudflare/workers-types"34export async function recordDuesPaymentAtomic(5 d1: D1Database,6 data: {7 memberId: string8 amount: number9 monthPaid: string10 receiptNumber: string11 paymentMethod: string12 receivedById: string13 }14) {15 // Cloudflare D1 batch API executes all prepared statements in a single atomic transaction16 const statements = [17 d1.prepare(`18 INSERT INTO DuesPayment (id, memberId, amount, monthPaid, receiptNumber, paymentMethod, receivedById, createdAt)19 VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)20 `).bind(21 crypto.randomUUID(),22 data.memberId,23 data.amount,24 data.monthPaid,25 data.receiptNumber,26 data.paymentMethod,27 data.receivedById28 ),By leveraging d1.batch(), all operations travel to the D1 primary coordinator in a single HTTP payload and commit together, ensuring strict ACID consistency without database locks lingering across network round trips.
3. Media Generation at the Edge: In-Memory Vector PDFs
A core requirement for Nabodigonto was providing members with official digital identity cards and verifiable PDF receipts for monthly donations.
The industry-standard way to generate PDFs in web apps is using headless Chrome (e.g. Puppeteer) to print an HTML page. However:
- Puppeteer requires a 300MB+ Chromium binary.
- It consumes hundreds of megabytes of RAM.
- Cloudflare Workers run on V8 isolates with a 128MB memory limit and cannot spawn OS subprocesses.
To overcome this, we built a dedicated media worker (services/media-service) that generates native vector PDFs entirely in memory using pdf-lib and @pdf-lib/fontkit.
The Bengali Unicode Font Rendering Problem
Standard PDF engines only support ASCII and Latin-1 character sets out of the box. Bengali is a complex Brahmic script with consonants, dependent vowel signs (matras), and conjunct glyphs (juktakkhor). If you attempt to draw raw Unicode strings into a canvas without a proper font shaper, characters render out of order or as unreadable glyph boxes.
To resolve this:
- We bundle TrueType Unicode font files (
Kalpurush.ttfandSolaimanLipi.ttf) as binary assets in the media worker. - We register
@pdf-lib/fontkiton thePDFDocumentinstance to enable full OpenType font shaping and glyph ligature substitution. - We draw vector elements, text, and dynamic QR verification codes directly onto a standardized PVC card canvas dimensions (3.375" x 2.125").
1// services/media-service/src/generators/member-card.ts2import { PDFDocument, rgb } from "pdf-lib"3import fontkit from "@pdf-lib/fontkit"4import QRCode from "qrcode"56export async function generateMemberIdCardPdf(member: {7 fullName: string8 memberNumber: string9 bloodGroup: string10 role: string11 verificationUrl: string12 fontBytes: ArrayBuffer13}): Promise<Uint8Array> {14 const pdfDoc = await PDFDocument.create()15 pdfDoc.registerFontkit(fontkit)1617 // Embed Bengali Unicode font18 const customFont = await pdfDoc.embedFont(member.fontBytes)1920 // Standard CR80 PVC Card Dimensions in points: 243 x 153 pt (85.6mm x 54mm)21 const page = pdfDoc.addPage([243, 153])22 const { width, height } = page.getSize()2324 // Draw background banner & branding25 page.drawRectangle({26 x: 0,27 y: height - 36,28 width,4. Zero-Filesystem Streaming with Cloudflare R2
Serverless workers have no persistent local disk. When generateMemberIdCardPdf finishes building the PDF bytes:
- The worker writes the raw
Uint8Arraydirectly to a Cloudflare R2 bucket (env.MEDIA_BUCKET.put()). - It assigns an immutable object key based on a hash of the content:
cards/${memberNumber}.pdf. - It returns a cryptographically signed URL or pipes the document stream with appropriate
Content-Type: application/pdfheaders.
Because generation is pure in-memory vector math, producing a complete card takes under 40ms and uses less than 25MB of memory, comfortably running within Cloudflare's serverless isolate limits.
5. Engineering Takeaways
- Batch at the Edge: When working with distributed databases like Cloudflare D1, combine dependent mutations into atomic
batch()calls to preserve consistency and avoid coordinator latency. - Isolate Document Rendering: Generating PDFs and images should never run on the primary API thread. Moving media generation to an isolated worker protects the main API from memory spikes.
- Direct Vector Assembly Beats Headless Browsers: For documents with structured layouts, building PDFs programmatically with
pdf-libis orders of magnitude faster and lighter than running headless browser instances.
In Part 3, we will explore how we engineered an asynchronous 4-channel notification queue across SMS, email, web push, and FCM, and scaled our serverless Bengali PDF generation pipeline.