11 min read

Asynchronous Multi-Channel Notification Queues & Serverless Bengali PDF Generation on the Edge

How we engineered a 4-channel Cloudflare Queues notification engine (SMS, Email, WebPush, FCM) and serverless Bengali font shaping with pdf-lib and R2.

ArchitectureBackendCloudflare WorkersLessons LearnedProjects

Asynchronous Multi-Channel Notification Queues & Serverless Bengali PDF Generation on the Edge

In the previous installments of this series, we covered monorepo architecture on Turborepo and relational data modeling with Cloudflare D1.

In this concluding article, we examine two of the most critical operational engines in Nabodigonto: how we engineered an asynchronous 4-channel notification queue to reach members across SMS, email, web push, and mobile push, and how we built an in-memory serverless Bengali vector PDF engine running on Cloudflare Workers and R2.


1. The Multi-Channel Communication Challenge in Bangladesh

When designing notifications for a grassroots welfare organization in South Asia, standard web push or transactional email alone is insufficient. Member demographics span widely different technological realities:

  • Rural & Elderly Members: Frequently do not own smartphones or check email accounts. They rely on direct telecom SMS for dues receipts, meeting notices, and emergency welfare disbursements.
  • Active Volunteers & University Students: Rely heavily on real-time Web Push (VAPID) in the browser and Mobile Push via Firebase Cloud Messaging (FCM) on the Expo/React Native mobile app.
  • Official Records & Donors: Donors require formal email receipts with attached vector PDF statements.

If the primary API worker tried to make sequential outbound HTTP requests to telecom SMS gateways and email APIs inside the request handler:

  1. Unacceptable Latency: Telecom SMS gateways in Bangladesh often have 1,500ms–3,000ms response latencies.
  2. Cascading Timeouts: A slow third-party API would hold the client's connection open, exhausting worker CPU time and risking HTTP 504 timeouts.
  3. Partial Delivery Failures: If the SMS dispatch succeeded but email failed, retrying the whole request would lead to duplicate SMS messages.

To solve this, we decoupled communication entirely using Cloudflare Queues.


2. The 4-Channel Queue Fanout Architecture

code
1[ Frontend Client ]
2 │
3 ▼ (POST /api/dues/collect)
4[ services/api ] ──► [ Cloudflare D1 ] (Atomic DB batch)
5 │
6 ▼ (env.NOTIFICATIONS_QUEUE.send())
7[ Cloudflare Queue: "nabodigonto-notifications" ]
8 │
9 ▼ (Batch consumer worker)
10[ services/notification-service ]
11 ├──► OneCodeSoft SMS Gateway (Direct carrier SMS)
12 ├──► Resend API (Transactional HTML + PDF receipts)
13 ├──► Web Push / VAPID (Browser notifications)
14 └──► Firebase Cloud Messaging (Expo mobile app)

Decoupled Producer

In services/api, the request handler performs its atomic D1 database operations, queues the notification payload, and immediately returns a response to the user in under 40ms:

ts
1// services/api/src/routes/dues.ts
2import { Hono } from "hono"
3import { DuesCollectSchema } from "@nabodigonto/contracts"
4
5export const duesRouter = new Hono().post("/collect", async (c) => {
6 const input = c.req.valid("json")
7
8 // 1. Commit atomic database mutations
9 const payment = await c.env.DB_SERVICE.recordDuesPaymentAtomic(input)
10
11 // 2. Publish async notification event to Cloudflare Queue
12 await c.env.NOTIFICATIONS_QUEUE.send({
13 type: "DUES_PAYMENT_CONFIRMED",
14 recipientPhone: input.phoneNumber,
15 recipientEmail: input.email,
16 fcmToken: input.fcmToken,
17 data: {
18 memberName: input.memberName,
19 month: input.month,
20 amount: input.amount,
21 receiptNumber: payment.receiptNumber,
22 receiptPdfUrl: `https://media.nabodigonto.org/receipts/${payment.receiptNumber}.pdf`,
23 },
24 })
25
26 return c.json({ success: true, receiptNumber: payment.receiptNumber }, 201)
27})

Resilient Queue Consumer

In services/notification-service, the consumer worker processes messages in batches with exponential backoff and dead-letter queueing:

ts
1// services/notification-service/src/index.ts
2export default {
3 async queue(batch: MessageBatch<NotificationPayload>, env: Env): Promise<void> {
4 for (const message of batch.messages) {
5 const { type, recipientPhone, recipientEmail, fcmToken, data } = message.body
6
7 try {
8 switch (type) {
9 case "DUES_PAYMENT_CONFIRMED":
10 // Channel 1: Bangladeshi SMS Gateway (OneCodeSoft API)
11 if (recipientPhone) {
12 await sendSmsViaOneCodeSoft(env.SMS_API_KEY, {
13 recipient: recipientPhone,
14 message: `নবদিগন্ত: আপনার ${data.month} মাসের চাঁদা ৳${data.amount} সফলভাবে গৃহীত হয়েছে। রশিদ নং: ${data.receiptNumber}`,
15 })
16 }
17
18 // Channel 2: Transactional Email via Resend
19 if (recipientEmail) {
20 await sendEmailReceipt(env.RESEND_API_KEY, {
21 to: recipientEmail,
22 subject: `Nabodigonto Dues Receipt - ${data.month}`,
23 receiptNumber: data.receiptNumber,
24 amount: data.amount,
25 downloadUrl: data.receiptPdfUrl,
26 })
27 }
28

3. Serverless Bengali PDF Generation Without Headless Browsers

A core feature of the platform is generating official digital membership ID cards and verifiable PDF receipts for monthly contributions.

The default approach in modern web engineering is using headless Chromium (Puppeteer / Playwright) to render HTML to PDF. However:

  • Chromium binaries exceed 300MB.
  • Headless browsers require hundreds of megabytes of RAM.
  • Cloudflare Workers run in V8 isolates with a 128MB memory limit and cannot execute Node.js 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.

Solving Bengali Unicode Font Shaping

Standard PDF engines only support Latin-1 or ASCII characters. Bengali is a Brahmic script that requires complex OpenType font shaping: consonants combine with vowel signs (matras) and conjunct glyphs (juktakkhor). Drawing raw Unicode strings without a font shaper results in broken, unreadable character sequences.

We resolved this by embedding custom TrueType fonts (Kalpurush.ttf and SolaimanLipi.ttf) and registering @pdf-lib/fontkit:

ts
1// services/media-service/src/generators/member-card.ts
2import { PDFDocument, rgb } from "pdf-lib"
3import fontkit from "@pdf-lib/fontkit"
4import QRCode from "qrcode"
5
6export async function generateMemberIdCardPdf(member: {
7 fullName: string
8 memberNumber: string
9 bloodGroup: string
10 role: string
11 verificationUrl: string
12 fontBytes: ArrayBuffer
13}): Promise<Uint8Array> {
14 const pdfDoc = await PDFDocument.create()
15 pdfDoc.registerFontkit(fontkit)
16
17 // Embed custom Bengali Unicode TrueType font
18 const customFont = await pdfDoc.embedFont(member.fontBytes)
19
20 // 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()
23
24 // Draw background header banner
25 page.drawRectangle({
26 x: 0,
27 y: height - 36,
28 width,

4. In-Memory Streaming to Cloudflare R2

Once the vector PDF bytes are assembled:

  1. The media worker streams the Uint8Array directly into Cloudflare R2 (env.MEDIA_BUCKET.put()).
  2. An immutable key is assigned (cards/${memberNumber}.pdf).
  3. The worker returns a signed download URL or streams the document with Content-Type: application/pdf headers.

Because this pipeline performs pure in-memory vector math without spawning browser instances, card generation completes in under 40ms and consumes less than 25MB of RAM, comfortably running within edge limits.


5. Engineering Reflections

Building Nabodigonto across more than 100,000 lines of code demonstrates that distributed, edge-native systems can solve real-world community problems reliably:

  1. Decouple Slow External APIs: Outbound telecom and email calls should never hold user HTTP requests hostage. Asynchronous queues guarantee responsiveness and provide built-in retries.
  2. In-Memory Vector Math Beats Headless Browsers: For structured documents, programmatic vector generation with pdf-lib and font shaping is faster, lighter, and thousands of times cheaper than running Chromium.
  3. Design for Local Reality: Software built for South Asian grassroots communities must adapt to diverse connectivity tiers, from carrier SMS on 2G feature phones to push notifications on modern mobile apps.