R
Railway2y ago
hstro

Video upload to my ExpressJS Railway app is suuuuper slow. If locally hosting server, super fast.

Hey there. I am a bit at a loss and admittedly a bit of a noob when it comes to file transfer things. I have a NextJS front-end and ExpressJS backend. The backend uses Multer. I want to upload a file (up to 300MB) to my backend but I find that a 150MB video takes 5-10 minutes to upload but my upload speed is 34Mbps, so it should take closer to 30seconds. I do not really know where to start looking, as I understand the problem could be in a number of places. I would hugely appreciate the chance to speak to someone with more experience. Thank you!
77 Replies
Percy
Percy2y ago
Project ID: ea5d7e7c-c61f-4f66-bfec-a5ec324949a1
hstro
hstroOP2y ago
ea5d7e7c-c61f-4f66-bfec-a5ec324949a1
Brody
Brody2y ago
interesting, I'll try to replicate this issue and get back to you
hstro
hstroOP2y ago
Thank you, really appreciate it
Brody
Brody2y ago
now what would you say your upload speed to railway tops out at?
hstro
hstroOP2y ago
I am not sure, how do I check this? Network tab? I got my other upload speed from fast.com
Brody
Brody2y ago
check in your task manager when uploading a file to your endpoint it will tell you the current upload speed
hstro
hstroOP2y ago
Apologies for noobiness. I'm on mac so do you mean checking something like utilities or is this a browser based task manager? I'm checking Utils now and network activity, looking for the railway connection
Brody
Brody2y ago
yeah mac has an equivalent to windows task manager, same deal
hstro
hstroOP2y ago
Ok so data sent/sec sitting around 99KB
Brody
Brody2y ago
yeah no
milo
milo2y ago
that isnt right
hstro
hstroOP2y ago
hstro
hstroOP2y ago
This is while I'm uploading the video
Brody
Brody2y ago
it would take a lotttttt longer than 5-10 mins at 99kb/s
hstro
hstroOP2y ago
Yeah Am I looking in the wrong place :p
Brody
Brody2y ago
youre looking at the wrong thing just look at the global upload speed
hstro
hstroOP2y ago
I followed this btw: "Click the "Network" tab in the Activity Monitor window to see your upload and download speeds. The red value next to "Data Sent/Sec" highlights your upload speed while the green value next to "Data Received/Sec" shows your download speed."
Brody
Brody2y ago
okay then nvm lol
hstro
hstroOP2y ago
cryingman super confused. I tried pinging my API endpoint but for some reason that isn't working.
Brody
Brody2y ago
damn bro tough times send me the railway domain for your api? do either of those work for you?
hstro
hstroOP2y ago
ping works fast And from my app the api routes do work I have to head off but will continue in a few hours
Brody
Brody2y ago
try uploading a file to https://utilities.up.railway.app/upload with postman. send a POST request with a binary type body, it will then send back info about request like upload speed. this endpoint is limited to file sizes under 200 megabytes and upload speeds are throttled at 50 megabits, but you mentioned a 150 megabyte file and 34 megabit upload speeds, so those limitations shouldn’t impose your test with that endpoint.
hstro
hstroOP2y ago
Thanks @Brody! I am picking this back up today.
Adam
Adam2y ago
I will note here that you won’t get speeds anywhere close to localhost levels. When transferring files locally, the files will transfer at the max speed of your drive, which is much much much faster than anything happening over a network
hstro
hstroOP2y ago
Ok so it looks muuuch better...: uploaded file size 117.29 Megabytes server received file in 34.28 seconds average upload speed 3.42 Megabytes/s or 27.37 Megabits/s
Brody
Brody2y ago
that seems about right for a single connection file upload when you said you have 34 Mbit upload speeds so that proves it's not an issue with railway or your network, just a code issue that endpoint you uploaded to streams the uploaded file directly to the disk on railway, so there's no limitation from writing to disk either where are you storing your uploaded files?
hstro
hstroOP2y ago
I'm using this code to do the upload from my client:
let config = {
baseURL: baseUrl || process.env.NEXT_PUBLIC_BASE_URL,
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress,
}
const jsonResp = await axios.post(apiRoute, formData, config)
let config = {
baseURL: baseUrl || process.env.NEXT_PUBLIC_BASE_URL,
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress,
}
const jsonResp = await axios.post(apiRoute, formData, config)
where formData is a FormData instance that I populate beforehand with the file. onUploadProgress is used to show a progress bar. Then the server-side receives it with:
router.post(
"/save-feedback-video",
securityChecks,
multer({ storage: storage }).single("feedbackVideo"),
save_feedback_video
);
router.post(
"/save-feedback-video",
securityChecks,
multer({ storage: storage }).single("feedbackVideo"),
save_feedback_video
);
The storage at this point is just to disk and temporary. The
save_feedback_video
save_feedback_video
does some processing but actually responds with 200 before any intensive processing is done, including saving the video to an external file storage. I really appreciate the help btw. This already helps a lot.
Brody
Brody2y ago
so uploaded file is saved to disk, server responds with 200, then your app does some processing and then sends the file off to an external storage?
hstro
hstroOP2y ago
Yep
Brody
Brody2y ago
and just the first step takes a long time, like you have dial up?
hstro
hstroOP2y ago
Yeah. I can see the upload starts immediately, as the progress bar inches forward straight away, but it goes super slow throughout
Brody
Brody2y ago
how does save_feedback_video save the file to disk? does it ever buffer into memory? could i see the function? also, have you tried uploading a file to your api with postman and not your frontend? if not, please try
hstro
hstroOP2y ago
So for that I rely on multer. I am quite new to multer and my understanding is it deals with getting the file out of the form-data and saving it to a specified storage path. route related code and multer:
// Custom storage configuration for multer
const storage = multer.diskStorage({
destination: function (
req: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) {
cb(null, "tmp-media/uploads/");
},
filename: function (
req: Request,
file: Express.Multer.File,
cb: (error: Error | null, filename: string) => void
) {
cb(null, file.originalname);
},
});

// Controller
import {
save_feedback_video,
save_feedback_annotation_media,
} from "../controllers/feedback";

router.post(
"/save-feedback-video",
securityChecks,
multer({ storage: storage }).single("feedbackVideo"),
save_feedback_video
);
// Custom storage configuration for multer
const storage = multer.diskStorage({
destination: function (
req: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) {
cb(null, "tmp-media/uploads/");
},
filename: function (
req: Request,
file: Express.Multer.File,
cb: (error: Error | null, filename: string) => void
) {
cb(null, file.originalname);
},
});

// Controller
import {
save_feedback_video,
save_feedback_annotation_media,
} from "../controllers/feedback";

router.post(
"/save-feedback-video",
securityChecks,
multer({ storage: storage }).single("feedbackVideo"),
save_feedback_video
);
save_feedback_video related code:
export const save_feedback_video = async (req: Request, res: Response) => {
console.log(getTimestampString() + " - save_feedback_video - request:", {
req,
});
if (!req.file) {
throw createError(400, "No file provided.");
}

// Required to make sure file cleanup is performed on error or success
const inputVideoPath = req.file.path;
const convertedFilePath = `tmp-media/converted/${
path.parse(req.file.originalname).name
}.mp4`;

try {
const { userType, idToken } = req.body;
console.log(getTimestampString() + " - save_feedback_video - params:", {
userType,
idToken,
});
const file = req.file;
let fileName = file.originalname.toLowerCase(); // Use original so we can avoid duplicating file content. Save once, use multiple times.

if (!(userType && file && fileName && idToken))
throw createError(400, "Bad params.");

const fileExtension = fileName.split(".").at(-1);
if (!fileExtension) throw createError(400, "Bad file extension.");

// Use original lowercase so we can avoid duplicating file content. Save once, use multiple times.
fileName = `${path.parse(req.file.originalname).name}.mp4`.toLowerCase(); // We assume the file will be mp4 due to the conversion step

// Ensure user of declared type is found]
const user = await getUser({ idToken, userType });
if (!user?.authUser?.uid)
throw createError(401, "Bad auth - could not find user.");

// Store media under appropriate story line storage path
const videoStoragePath = getVideoStoragePath(
user.authUser.uid,
fileName // extension already on fileName
// `${fileName}.${extension}`
);

// Respond early and save media in background
res.status(200).json({
message: "File upload successful."
});
...
export const save_feedback_video = async (req: Request, res: Response) => {
console.log(getTimestampString() + " - save_feedback_video - request:", {
req,
});
if (!req.file) {
throw createError(400, "No file provided.");
}

// Required to make sure file cleanup is performed on error or success
const inputVideoPath = req.file.path;
const convertedFilePath = `tmp-media/converted/${
path.parse(req.file.originalname).name
}.mp4`;

try {
const { userType, idToken } = req.body;
console.log(getTimestampString() + " - save_feedback_video - params:", {
userType,
idToken,
});
const file = req.file;
let fileName = file.originalname.toLowerCase(); // Use original so we can avoid duplicating file content. Save once, use multiple times.

if (!(userType && file && fileName && idToken))
throw createError(400, "Bad params.");

const fileExtension = fileName.split(".").at(-1);
if (!fileExtension) throw createError(400, "Bad file extension.");

// Use original lowercase so we can avoid duplicating file content. Save once, use multiple times.
fileName = `${path.parse(req.file.originalname).name}.mp4`.toLowerCase(); // We assume the file will be mp4 due to the conversion step

// Ensure user of declared type is found]
const user = await getUser({ idToken, userType });
if (!user?.authUser?.uid)
throw createError(401, "Bad auth - could not find user.");

// Store media under appropriate story line storage path
const videoStoragePath = getVideoStoragePath(
user.authUser.uid,
fileName // extension already on fileName
// `${fileName}.${extension}`
);

// Respond early and save media in background
res.status(200).json({
message: "File upload successful."
});
...
I haven't. I will need to play with some stuff to pass the auth/security checks if I send via postman
Brody
Brody2y ago
could you please attempt?
hstro
hstroOP2y ago
Sure, it might take a little while but I'll report back 🙂 Btw is it generally better to send via binary or form-data ?
Brody
Brody2y ago
my api accepts form data too, if you wanted to check for any speed differences. do you send other data along side the file? if so, then you need formdata
hstro
hstroOP2y ago
I do send other data
Brody
Brody2y ago
then yeah you need a form
hstro
hstroOP2y ago
It seems to have taken far less time It hits an error because I couldn't provide a good request, but I got far enough that the file will have been uploaded Says 40s
Brody
Brody2y ago
same file that you tested my endpoint with?
hstro
hstroOP2y ago
Yep! Slightly separate note, Is it possible to ssh into Railway deployments?
Brody
Brody2y ago
well 40 seconds isn't that far off from 34.28 with my api it's not, why do you ask?
hstro
hstroOP2y ago
I wanted to double check the video file is properly deleted once it is processed
Brody
Brody2y ago
ah I see
hstro
hstroOP2y ago
Yeah so I guess we've narrowed the problem to the client-side js I have?
Brody
Brody2y ago
add another middleware at the end that checks for the file and logs results yeah you're using axios, switch to the browser native fetch you showed me the client side axios request, it would be super easy to swap that to fetch also axios isn't that good tbh personally Ive always had problems with axios not this problem, but still
hstro
hstroOP2y ago
Ah yeah, nice! Ok I'll try this and report back
Brody
Brody2y ago
sounds good
hstro
hstroOP2y ago
So I tried the native fetch and it didn't seem to help. I don't get the progress bar functionality so it's hard to tell if the upload starts, but from my side after waiting for a long time (>3 minutes) the upload still hasn't completed I will try uploading to your endpoint from my client to see if that is also slow Ok so hosting my client locally and pointing the file at your endpoint is also slow, so this must be a problem with my code Sending to your endpoint is also slow using native fetch
Brody
Brody2y ago
but postman was fast, so it must be client side issue I will try writing a file upload client in html/js and see how that goes but it will be much later today I might even see about adding a progress bar to it 😉
hstro
hstroOP2y ago
Thanks Brody. I tried a bunch of things today. I have tried uploading using both of these code snippets: Attempt 1:
const TestPage = () => {
function customUpload(file, apiRoute, baseUrl) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", `${baseUrl}${apiRoute}`, true)
xhr.setRequestHeader("Content-Type", "multipart/form-data")
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
const progress = (event.loaded / event.total) * 100
console.log(`Upload progress: ${progress.toFixed(2)}%`)
}
}
xhr.onload = function () {
if (xhr.status === 200) {
console.log("Upload complete")
resolve(xhr.responseText)
} else {
console.error("Error during the upload")
reject(new Error(`HTTP error ${xhr.status}: ${xhr.statusText}`))
}
}
xhr.onerror = function () {
console.error("Error during the upload")
reject(new Error(`HTTP error ${xhr.status}: ${xhr.statusText}`))
}
const formData = new FormData()
formData.append("file", file)
xhr.send(formData)
})
}

return (
<div>
<input
type="file"
onChange={async (e) => {
console.log({ e })
const file = e.target.files[0]
const res = await customUpload(file, "/upload", "https://utilities.up.railway.app")
console.log({ res })
}}
/>
</div>
)
}

export default TestPage
const TestPage = () => {
function customUpload(file, apiRoute, baseUrl) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", `${baseUrl}${apiRoute}`, true)
xhr.setRequestHeader("Content-Type", "multipart/form-data")
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
const progress = (event.loaded / event.total) * 100
console.log(`Upload progress: ${progress.toFixed(2)}%`)
}
}
xhr.onload = function () {
if (xhr.status === 200) {
console.log("Upload complete")
resolve(xhr.responseText)
} else {
console.error("Error during the upload")
reject(new Error(`HTTP error ${xhr.status}: ${xhr.statusText}`))
}
}
xhr.onerror = function () {
console.error("Error during the upload")
reject(new Error(`HTTP error ${xhr.status}: ${xhr.statusText}`))
}
const formData = new FormData()
formData.append("file", file)
xhr.send(formData)
})
}

return (
<div>
<input
type="file"
onChange={async (e) => {
console.log({ e })
const file = e.target.files[0]
const res = await customUpload(file, "/upload", "https://utilities.up.railway.app")
console.log({ res })
}}
/>
</div>
)
}

export default TestPage
Attempt 2:
const TestPage = () => {
return (
<form
action="https://utilities.up.railway.app/upload"
method="post"
enctype="multipart/form-data"
>
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
)
}
export default TestPage
const TestPage = () => {
return (
<form
action="https://utilities.up.railway.app/upload"
method="post"
enctype="multipart/form-data"
>
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
)
}
export default TestPage
Both had exactly the same (slow) performance. It's so strange. I am using NextJS 13.2.3 so I don't know if this version is causing some problem...
Brody
Brody2y ago
nah I wouldn't think that code would have anything to do with nextjs, since that code is just vanilla js that's ran by the browser now you said you tried using fetch, but that example uses xmlhttprequest, and axios uses xmlhttprequest when running in the browser, so technically you didn't change much ignore the progress bar thing for now and just try with fetch, don't try to implement everything, just test form file uploads with fetch, keep it simple
hstro
hstroOP2y ago
Ah I also used this code:
async function submitForm(params, apiRoute, baseUrl) {
try {
// Start the timer
console.time('submitForm');

// Create a new FormData instance
const formData = new FormData();

// Iterate over the params object and append each field to the FormData
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key]);
}
}

// Set up the fetch configuration object
const config = {
method: 'POST',
body: formData
};

// Send the POST request with the form data
const response = await fetch(`${baseUrl}${apiRoute}`, config);

// Check if the response is successful
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}

// Handle the response as needed
const responseData = await response.json();

// Stop the timer and log the elapsed time
console.timeEnd('submitForm');

console.log(response.status, responseData);
return responseData;
} catch (error) {
// Stop the timer and log the elapsed time in case of an error
console.timeEnd('submitForm');

console.error('Error while submitting the form:', error.message);
throw error;
}
}
async function submitForm(params, apiRoute, baseUrl) {
try {
// Start the timer
console.time('submitForm');

// Create a new FormData instance
const formData = new FormData();

// Iterate over the params object and append each field to the FormData
for (const key in params) {
if (params.hasOwnProperty(key)) {
formData.append(key, params[key]);
}
}

// Set up the fetch configuration object
const config = {
method: 'POST',
body: formData
};

// Send the POST request with the form data
const response = await fetch(`${baseUrl}${apiRoute}`, config);

// Check if the response is successful
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}

// Handle the response as needed
const responseData = await response.json();

// Stop the timer and log the elapsed time
console.timeEnd('submitForm');

console.log(response.status, responseData);
return responseData;
} catch (error) {
// Stop the timer and log the elapsed time in case of an error
console.timeEnd('submitForm');

console.error('Error while submitting the form:', error.message);
throw error;
}
}
And the "Attempt 2" above used a simple form submit Not sure what API the <form> uses though
Brody
Brody2y ago
what browser are you using
hstro
hstroOP2y ago
Brave, but I also tried Safari and also tried on my phone I just updated to NextJS 13.3 for the sake of it, but not luck Haha this is so weird
Brody
Brody2y ago
this is super weird we will get to the bottom of it
hstro
hstroOP2y ago
I'm quite excited to find the reason, I hope it's something good
Brody
Brody2y ago
I bet it will be something stupid
hstro
hstroOP2y ago
Yup 😬
Brody
Brody2y ago
wonder what framework postman uses for requests, since postman is literally a browser window
hstro
hstroOP2y ago
So I asked ChatGPT :p Postman is an Electron-based application, which means it is built on top of Chromium and Node.js. While it shares some similarities with a browser, Postman is specifically designed and optimized for working with APIs, unlike web browsers that cater to a wide range of use cases and prioritize security and user experience. When making requests, Postman does not use the same APIs as web browsers, like fetch or XMLHttpRequest. Instead, it uses Node.js libraries to make HTTP requests, such as the built-in http and https modules, or third-party libraries like request (now deprecated) or axios. Since Postman is built on top of Node.js and uses server-side libraries for making requests, it is not subject to the same security restrictions and limitations as web browsers. Additionally, Postman is specifically optimized for working with APIs, which means it may have a more efficient implementation for handling file uploads compared to general-purpose web browsers. Although Postman runs in an Electron-based environment, which uses Chromium under the hood, it should not be considered equivalent to a regular web browser in terms of functionality and performance characteristics. The difference in upload speed you are experiencing between Postman and web browsers can be attributed to these differences in implementation and optimization for API-related tasks. Not sure if it is hallucinating
Brody
Brody2y ago
nope that's all correct it would make sense that api calls are being done from the nodejs side of the postman app and not client side that is still chromium
hstro
hstroOP2y ago
Wouldn't that mean the file would have to be sent the their back-end before being sent elsewhere? I just created an index.html file on my computer and stuck in:
<!DOCTYPE html>
<html>
<head>
<title>Simple File Upload</title>
</head>
<body>
<form
action="https://utilities.up.railway.app/upload"
method="post"
enctype="multipart/form-data"
>
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>Simple File Upload</title>
</head>
<body>
<form
action="https://utilities.up.railway.app/upload"
method="post"
enctype="multipart/form-data"
>
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
</body>
</html>
results were:
uploaded file size 8.23 Megabytes
server received file in 27.18 seconds
average upload speed 0.30 Megabytes/s or 2.42 Megabits/s
uploaded file size 8.23 Megabytes
server received file in 27.18 seconds
average upload speed 0.30 Megabytes/s or 2.42 Megabits/s
so no change
Brody
Brody2y ago
no because nodejs can just read the file off disk and stream it to the endpoint
hstro
hstroOP2y ago
Oh right. That's cool
Brody
Brody2y ago
I wanna get into tests myself, but I have a busy day ahead of me and won't be on the computer for a while
hstro
hstroOP2y ago
Thanks, I will be around till late today. Really appreciate your help mate 🙂
Brody
Brody2y ago
no problem!
hstro
hstroOP2y ago
Hey @Brody. Do you think you'd be able to do tests today? I didn't have any luck during the rest of the day. I need to fix it somehow as the website is customer facing
Brody
Brody2y ago
I've just said this in another help thread but I'll just paste it here for you too. I have been extremely busy with work around the house, just had a wind storm that ripped off a bunch of siding among others things around the house that need attention.
hstro
hstroOP2y ago
Oh man! That sucks! Very sorry to hear it. Hope it's not too much work to recover
Brody
Brody2y ago
not many pieces came off, it's just a type of siding that is a pain to put on update, I have some good but mostly bad news. the good: I now know the determining factor as to why uploading data to railway is slow. whenever uploads are done with http 2 theyre slow, but uploads done with http 1.1 are fast (by default postman uses http 1.1, and insomnia uses 2.0). this issue is solely with railway. the bad: I don’t know why this is the case, and you cant change to http 1.1 because thats entirely up to the browser, and any modern browser will default to http 2. Ill be talking to the team about this as soon as I can
hstro
hstroOP2y ago
Oooh very interesting. I would have never found that on my own - thank you very much 🙂 Is it reasonable to assume that some day the uploads will be faster without me needing to change my code? I'd love to hear why this is happening when you all find the answer 👀
Brody
Brody2y ago
yes I can confidently say that the team wouldn't let an issue like this slide for very long once they find out, I'll update you when I have more information can you see this thread? https://discord.com/channels/713503345364697088/1096840076396343297
hstro
hstroOP2y ago
Yeah I saw it quite late. Thanks a lot for putting it together
Want results from more Discord servers?
Add your server