DEV Community

Mahesh
Mahesh

Posted on

Why We Built MailPort: A Modern Streaming Email SDK for Node.js

How I Streamed a 100 MB Email Attachment in Node.js Without Buffering the Complete File

Target Publication: Dev.to / Hashnode / Medium
Tags: #nodejs #typescript #webdev #performance #email


The Problem with Naive Email Attachment Buffering

In standard Node.js email implementations, sending a multi-megabyte email attachment often loads the entire binary file into a Node.js Buffer in memory.

When encoding a 100 MB attachment to Base64:

  1. The 100 MB raw buffer is loaded into heap memory.
  2. Base64 encoding creates a ~133 MB string.
  3. MIME line wrapping creates another string object.
  4. Total transient memory footprint exceeds 300+ MB for a single attachment!

If multiple concurrent requests stream large attachments, Node.js applications frequently experience V8 heap inflation, garbage collection pauses, or Out-Of-Memory (OOM) crashes.


The AsyncIterable Streaming Solution

To eliminate buffer inflation, MailPort's MIME engine (@mailport/mime) processes attachment data incrementally using AsyncIterable<Uint8Array> streams.

1. Base64 Chunk Boundary Handling

Base64 encoding transforms groups of 3 binary bytes into 4 ASCII characters. When streaming arbitrary file chunks (e.g. 64 KB or 10 MB chunks), a chunk boundary may split across a 3-byte tuple.

Base64StreamEncoder buffers only the leftover 1 or 2 bytes across chunk boundaries:

export class Base64StreamEncoder {
  #remainder: Uint8Array = new Uint8Array(0);

  encodeChunk(chunk: Uint8Array): Uint8Array {
    // Combine 1-2 remainder bytes with incoming chunk
    // Process in multiples of 3 bytes
    // Preserve new remainder for next chunk
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Direct Socket Pipeline

The generated AsyncIterable<Uint8Array> stream feeds directly into the TCP/TLS socket write queue with backpressure handling (socket.write() + drain event listener).


Empirical Benchmark Results

We benchmarked streaming a 100 MB attachment on Node.js v24 (macOS ARM64):

Metric Result
Emitted Transmitted Payload 136.84 MB
Throughput 387.57 MB/s
Duration 353 ms
Peak Heap Memory Delta 8.98 MB

Peak heap memory usage remained under 9 MB while processing a 136 MB emitted payload!


Code Example

import { createReadStream } from 'node:fs';
import { createMailer, smtp } from 'mailport';

const mailer = createMailer({
  transport: smtp({
    host: process.env.SMTP_HOST!,
    port: 587,
    auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASSWORD! },
  }),
});

await mailer.send({
  from: 'exports@example.com',
  to: 'user@example.com',
  subject: 'Monthly Data Backup',
  attachments: [
    {
      type: 'stream',
      filename: 'backup.sql.gz',
      stream: createReadStream('./backup.sql.gz'),
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

Check out the MailPort repository on GitHub: https://github.com/mahe-gi/mailport

Top comments (0)