Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL

Uploading large files reliably is tough — browser crashes, flaky internet, or sudden interruptions can ruin everything.

This tutorial shows how to implement resumable, chunked uploads to Amazon S3, using:

🧠 multipart upload API from AWS SDK
🪣 Node.j…


This content originally appeared on DEV Community and was authored by Aamir saleem lone

Uploading large files reliably is tough — browser crashes, flaky internet, or sudden interruptions can ruin everything.

This tutorial shows how to implement resumable, chunked uploads to Amazon S3, using:

  • 🧠 multipart upload API from AWS SDK
  • 🪣 Node.js + Express + Multer
  • 💾 MySQL for upload session tracking
  • ✅ Clean chunk validation & completion logic

🔧 Tech Stack

  • Backend: Node.js + Express
  • Frontend: React.js
  • Database: MySQL (for session/chunk tracking)
  • Storage: AWS S3 (multipart upload)
  • Middleware: Multer

🗂️ Core Concepts

  • Init Upload: Creates multipart upload session and saves to DB
  • Upload Chunk: Sends chunk with part number to S3
  • Check Chunk: Avoid duplicate uploads
  • Complete Upload: Merges parts and cleans up
  • Fallback: Automatically retries on failures using resumable clients

📁 Folder Structure

/helpers/awsConfig.js
/config/db.js
/controllers/uploadController.js

🚀 Upload Controller Code

const path = require('path');
const { S3Config } = require('../../helper/awsConfig');
const bucketName = 'docintel';
const mysqlLib = require('../../config/db');

const asyncHandler = (fn) => (req, res) => {
  Promise.resolve(fn(req, res)).catch((err) => {
    console.error(err);
    res.status(500).json({ error: err.message });
  });
};

📂 Get Folder Name Based on Type

const getFolderName = async (type, method = null, authId) => {
  if (method == 'libraryType') {
    let [data] = await mysqlLib.executeQuery(
      'select folder_name from users where id = ?',
      [authId]
    );
    return `video/${data.folder_name}`;
  }

  const map = {
    image: 'dummy_images',
    video: 'dummy_videos',
    audio: 'dummy_audios',
    document: 'dummy_documents',
  };

  return map[type] || map.document;
};

🔄 API Endpoints

1️⃣ Upload Single File to S3 (non-chunked)

exports.uploadFileToAws = asyncHandler(async (req, res) => {
  const { type, name } = req.body;
  const file = req.file;
  if (!file) throw new Error('No file uploaded');

  const ext = path.extname(file.originalname).toLowerCase();
  if (!/\.(jpeg|jpg|png|gif|svg|mp3|mp4|pdf)$/.test(ext)) {
    throw new Error('Unsupported file type');
  }

  const folderName = getFolderName(type);
  const fileName = `${path.parse(name).name}_${Date.now()}${ext}`.replace(/\s+/g, '_');
  const fullPath = `${folderName}/${fileName}`;

  const result = await S3Config.upload({
    Bucket: bucketName,
    Key: fullPath,
    Body: file.buffer,
    ACL: 'public-read',
  }).promise();

  res.status(200).json({ message: 'File uploaded successfully', data: result.Location });
});

2️⃣ Initialize Multipart Upload

exports.initUpload = asyncHandler(async (req, res) => {
  const { authId, body } = req;
  const { filename, type, method } = body;
  if (!filename || !type) throw new Error('Missing filename or type');

  const folderName = await getFolderName(type, method, authId);
  const s3Key = `${folderName}/${Date.now()}_${filename}`;

  const { UploadId: uploadId } = await S3Config.createMultipartUpload({
    Bucket: bucketName,
    Key: s3Key,
    ACL: 'public-read',
  }).promise();

  await mysqlLib.executeQuery(
    'INSERT INTO upload_sessions (upload_id, s3_key, type) VALUES (?, ?, ?)',
    [uploadId, s3Key, type]
  );

  res.status(200).json({ uploadId, s3Key });
});

3️⃣ Check if Chunk Exists

exports.checkUpload = asyncHandler(async (req, res) => {
  const { resumableChunkNumber, uploadId } = req.query;

  const [session] = await mysqlLib.executeQuery(
    'SELECT * FROM upload_sessions WHERE upload_id = ?',
    [uploadId]
  );
  if (!session) return res.status(404).send('Upload session not found');

  const [part] = await mysqlLib.executeQuery(
    'SELECT * FROM upload_parts WHERE upload_id = ? AND part_number = ?',
    [uploadId, resumableChunkNumber]
  );

  return part ? res.status(200).send('Chunk exists') : res.status(404).send('Chunk not found');
});

4️⃣ Upload Chunk to S3

exports.uploadChunk = asyncHandler(async (req, res) => {
  const { resumableChunkNumber, uploadId } = req.body;

  const [session] = await mysqlLib.executeQuery(
    'SELECT * FROM upload_sessions WHERE upload_id = ?',
    [uploadId]
  );
  if (!session) throw new Error('Invalid upload session');

  const partNumber = parseInt(resumableChunkNumber);
  const result = await S3Config.uploadPart({
    Bucket: bucketName,
    Key: session.s3_key,
    PartNumber: partNumber,
    UploadId: uploadId,
    Body: req.file.buffer,
  }).promise();

  await mysqlLib.executeQuery(
    'INSERT INTO upload_parts (upload_id, part_number, etag) VALUES (?, ?, ?)',
    [uploadId, partNumber, result.ETag]
  );

  res.status(200).json({
    message: 'Chunk uploaded',
    ETag: result.ETag,
    PartNumber: partNumber,
  });
});

5️⃣ Complete Multipart Upload

exports.completeUpload = asyncHandler(async (req, res) => {
  const { uploadId } = req.body;

  const [session] = await mysqlLib.executeQuery(
    'SELECT * FROM upload_sessions WHERE upload_id = ?',
    [uploadId]
  );
  if (!session) throw new Error('Invalid upload session');

  const parts = await mysqlLib.executeQuery(
    'SELECT part_number, etag FROM upload_parts WHERE upload_id = ? ORDER BY part_number ASC',
    [uploadId]
  );

  const sortedParts = parts.map(part => ({
    PartNumber: parseInt(part.part_number),
    ETag: part.etag
  }));

  const result = await S3Config.completeMultipartUpload({
    Bucket: bucketName,
    Key: session.s3_key,
    UploadId: uploadId,
    MultipartUpload: { Parts: sortedParts },
  }).promise();

  // Optional cleanup
  await mysqlLib.executeQuery('DELETE FROM upload_parts WHERE upload_id = ?', [uploadId]);
  await mysqlLib.executeQuery('DELETE FROM upload_sessions WHERE upload_id = ?', [uploadId]);

  res.status(200).json({
    message: 'Upload completed',
    url: decodeURIComponent(result.Location),
    key: result.Key,
  });
});

✅ Database Tables (MySQL)

CREATE TABLE `upload_sessions` (
  `id` INT AUTO_INCREMENT PRIMARY KEY,
  `upload_id` VARCHAR(255),
  `s3_key` VARCHAR(512),
  `type` VARCHAR(50),
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE `upload_parts` (
  `id` INT AUTO_INCREMENT PRIMARY KEY,
  `upload_id` VARCHAR(255),
  `part_number` INT,
  `etag` VARCHAR(255),
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

🧠 Final Thoughts

Why this works:

  • File uploads won't break on slow connections
  • You can resume failed uploads
  • Saves bandwidth by checking existing chunks
  • Scales beautifully with S3's multipart APIs

** Frontend Using React js
We’ll use:

  • Resumable.js for chunked uploads
  • React for UI
  • Axios for making API calls

* Dummy API endpoints to simulate a real-world system

🧩 What You'll Build

✅ File validation (size, type)
✅ Chunked upload via multipart upload
✅ Pause/Resume/Cancel support
✅ Real-time progress and speed
✅ Retry on failure
✅ Finalization after upload

📦 Dependencies

Install the following if you haven't:

npm install resumablejs axios react-bootstrap react-circular-progressbar react-toastify

🛠 Backend API Expectations

Assume the following dummy endpoints (you can mock these for dev):

Endpoint Method Description
/api/init-upload POST Initialize multipart upload
/api/upload POST Upload a file chunk
/api/complete-upload POST Complete multipart upload
/api/check-chunk GET Check if a chunk exists (optional)

📁 Frontend Uploader Component

// ResumableUploader.jsx
import React, { useEffect, useRef, useState } from "react";
import Resumable from "resumablejs";
import axios from "axios";
import { Modal, Button } from "react-bootstrap";
import { CircularProgressbar } from "react-circular-progressbar";
import { toast } from "react-toastify";

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["application/pdf", "image/png", "image/jpeg", "video/mp4"];
const MAX_SIZE_MB = 1024;

const ResumableUploader = ({ label, onSuccess }) => {
  const resumableRef = useRef(null);
  const uploadId = useRef(null);
  const s3Key = useRef(null);
  const browseRef = useRef(null);
  const activeUpload = useRef(null);

  const [progress, setProgress] = useState(0);
  const [uploading, setUploading] = useState(false);
  const [status, setStatus] = useState("");

  const initUploadSession = async (file) => {
    try {
      const response = await axios.post("https://dummyapi.com/api/init-upload", {
        filename: file.name,
        type: file.type,
      });
      uploadId.current = response.data.uploadId;
      s3Key.current = response.data.s3Key;
      return true;
    } catch (err) {
      toast.error("Failed to initialize upload.");
      return false;
    }
  };

  const validateFile = (file) => {
    if (!ALLOWED_TYPES.includes(file.type)) {
      toast.error("Unsupported file type");
      return false;
    }
    if (file.size > MAX_SIZE_MB * 1024 * 1024) {
      toast.error("File too large");
      return false;
    }
    return true;
  };

  const completeUpload = async () => {
    try {
      const response = await axios.post("https://dummyapi.com/api/complete-upload", {
        uploadId: uploadId.current,
      });
      toast.success("Upload complete!");
      onSuccess?.(response.data.url);
    } catch (err) {
      toast.error("Upload finalization failed.");
    } finally {
      setUploading(false);
      setProgress(0);
      uploadId.current = null;
      s3Key.current = null;
      activeUpload.current = null;
    }
  };

  const handleFileAdded = async (file) => {
    if (!validateFile(file.file)) {
      resumableRef.current.removeFile(file);
      return;
    }

    setUploading(true);
    const initialized = await initUploadSession(file.file);
    if (!initialized) return;

    resumableRef.current.opts.query = { uploadId: uploadId.current };
    activeUpload.current = file;
    resumableRef.current.upload();
  };

  const handleProgress = (file) => {
    const percent = Math.floor(file.progress() * 100);
    setProgress(percent);
  };

  const handleSuccess = () => {
    completeUpload();
  };

  const handleError = (file, message) => {
    console.error("Upload error:", message);
    toast.error("Upload failed.");
    setUploading(false);
  };

  useEffect(() => {
    const r = new Resumable({
      target: "https://dummyapi.com/api/upload",
      query: {},
      fileParameterName: "file",
      chunkSize: CHUNK_SIZE,
      simultaneousUploads: 3,
      testChunks: false,
      maxFiles: 1,
      throttleProgressCallbacks: 1,
      headers: {
        auth: "Bearer token",
      },
      fileType: ALLOWED_TYPES,
    });

    r.assignBrowse(browseRef.current);
    r.on("fileAdded", handleFileAdded);
    r.on("fileProgress", handleProgress);
    r.on("fileSuccess", handleSuccess);
    r.on("fileError", handleError);

    resumableRef.current = r;

    return () => {
      r.cancel();
    };
  }, []);

  return (
    <>
      <Button ref={browseRef} disabled={uploading}>
        {uploading ? "Uploading..." : label}
      </Button>

      <Modal show={uploading} centered backdrop="static">
        <Modal.Header>
          <Modal.Title>Uploading</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <CircularProgressbar value={progress} text={`${progress}%`} />
          <p className="mt-2">{status}</p>
          <Button variant="danger" onClick={() => resumableRef.current.cancel()}>
            Cancel
          </Button>
        </Modal.Body>
      </Modal>
    </>
  );
};

export default ResumableUploader;

🔧 Notes

  • Replace https://dummyapi.com/api/... with your actual backend endpoints.
  • Use onSuccess callback to handle uploaded file URLs (e.g., save to DB).
  • The resumablejs library manages chunking and retrying for you!

🧪 Example Usage

<ResumableUploader
  label="Upload Video"
  onSuccess={(url) => console.log("Uploaded to:", url)}
/>

🧠 Wrap Up

This setup provides a robust resumable upload experience, with built-in retry, progress feedback, and finalization using AWS S3 multipart under the hood. It's production-ready and highly extensible.

Tags: react aws s3 upload resumablejs axios frontend


This content originally appeared on DEV Community and was authored by Aamir saleem lone


Print Share Comment Cite Upload Translate Updates
APA

Aamir saleem lone | Sciencx (2025-07-22T07:05:28+00:00) Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL. Retrieved from https://www.scien.cx/2025/07/22/scalable-resumable-file-uploads-to-s3-with-node-js-aws-sdk-mysql/

MLA
" » Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL." Aamir saleem lone | Sciencx - Tuesday July 22, 2025, https://www.scien.cx/2025/07/22/scalable-resumable-file-uploads-to-s3-with-node-js-aws-sdk-mysql/
HARVARD
Aamir saleem lone | Sciencx Tuesday July 22, 2025 » Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL., viewed ,<https://www.scien.cx/2025/07/22/scalable-resumable-file-uploads-to-s3-with-node-js-aws-sdk-mysql/>
VANCOUVER
Aamir saleem lone | Sciencx - » Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/07/22/scalable-resumable-file-uploads-to-s3-with-node-js-aws-sdk-mysql/
CHICAGO
" » Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL." Aamir saleem lone | Sciencx - Accessed . https://www.scien.cx/2025/07/22/scalable-resumable-file-uploads-to-s3-with-node-js-aws-sdk-mysql/
IEEE
" » Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL." Aamir saleem lone | Sciencx [Online]. Available: https://www.scien.cx/2025/07/22/scalable-resumable-file-uploads-to-s3-with-node-js-aws-sdk-mysql/. [Accessed: ]
rf:citation
» Scalable Resumable File Uploads to S3 with Node.js, AWS SDK & MySQL | Aamir saleem lone | Sciencx | https://www.scien.cx/2025/07/22/scalable-resumable-file-uploads-to-s3-with-node-js-aws-sdk-mysql/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.