האינטרנט המוקדם התבסס על טקסט פשוט. הקלדת שם משתמש או תגובה בטופס, לחיצה על שליחה, ומחרוזת קצרה נשלחה לשרת באמצעות HTTP. מחזור הבקשה-תגובה הפשוט הזה הגדיר את התשתית. כשבני אדם התחילו לרצות לשתף תמונות, מסמכים וסרטונים, מהנדסים נאלצו להבין איך להעביר נתונים בינאריים גולמיים דרך מערכת שנבנתה כולה עבור טקסט קריא.

אם תנסו להכניס קובץ JPEG לתוך אובייקט JSON, תיתקלו במחסום יסודי. JSON הוא פרוטוקול טקסט. הוא מצפה לתווי Unicode תקפים, גרשיים, סוגריים ומחרוזות עם escape תקין. קובץ בינארי הוא פשוט רצף ארוך של בתים (bytes), שרבים מהם אינם ניתנים לייצוג קריא. אם תדחסו את הבתים הללו לתוך מחרוזת JSON, ה-parser יישבר, רצפי ה-escape יהרסו את ה-payload, וההודעה כולה תהפוך לבלתי קריאה בצד השני.

קידוד Base64 הופיע כפתרון המעקף הברור. הוא ממפה מחדש נתונים בינאריים לקבוצה מוגבלת של שישים וארבעה תווים של ASCII קריאים. כל שלושה בתים של נתונים בינאריים הופכים לארבעה תווים של טקסט. ה-payload הוא כעת JSON חוקי, מה שאומר שהוא ישרוד מסע דרך כל API סטנדרטי. אך המחיר הוא מיידי. הקידוד מחדש הזה מנפח את גודל הקובץ בכ-33%. תמונה בת שלושה מגה-בייט הופכת לארבעה מגה-בייט על גבי הרשת. הן הלקוח והן השרת צורכים מחזורי CPU נוספים כדי לתרגם את הנתונים הלוך ושוב. חשוב מכך, מסגרות עבודה (frameworks) רבות של שרתי JSON קוראות את כל גוף ההודעה לזיכרון לפני ה-parsing שלה. מספר קטן של העלאות גדולות בו-זמנית יכול להכריע שרת צנוע, כיוון שכל אחת מהן מוחזקת ב-RAM כמחרוזת טקסט כבדה עוד לפני שהיא נשמרת בדיסק. Base64 עובד במצבי חירום, אך הוא מעולם לא תוכנן לשאת קבצים כבדים בקנה מידה של ייצור (production scale).

התשובה הטובה יותר היא multipart/form-data. פורמט זה מתייחס לבקשת HTTP בודדת כאוסף של חלקים נפרדים, כאשר כל חלק מופרד על ידי מחרוזת boundary ייחודית. חלק אחד עשוי להכיל שדה טקסט פשוט. החלק הבא עשוי להכיל תמונה בינארית גולמית, המסומנת בכותרות Content-Type ו-Content-Disposition משלה. השרת קורא את הזרם (stream) הנכנס באופן רציף, מחפש את סימני ה-boundary, ומעביר כל חלק ל-handler המתאים מבלי להזדקק להתייחס לכל ה-payload כאל בלוק טקסט אחד.

ב-Node.js, ההבחנה הזו חשובה במיוחד. ה-middleware של express.json() יודע לנתח (parse) גופי JSON, אך הוא אינו מטפל בזרמי קבצים (file streams). כדי לעבד העלאות multipart, דרוש streaming parser כמו Multer או Busboy. כלים אלו מתחברים לזרם הבקשה הגולמי וקוראים אותו chunk by chunk. Multer, למשל, מאפשר לכם לבחור אם לכתוב קבצים נכנסים לתיקייה זמנית בדיסק או להחזיק קבצים קטנים יותר בזיכרון. ההחלטה על הקונפיגורציה הזו קריטית. אם תשמרו הכל בזיכרון והאפליקציה שלכם תקבל פתאום מספר קבצים גדולים בבת אחת, התהליך עלול להיגמר מה-heap space ולקרוס. כתיבה לדיסק מהווה פשרה של I/O עבור יציבות, אך היא מעלה שאלות משלה לגבי ניקוי וביטחון נתיבים (path security).

עבור אפליקציה קטנה, שמירת קבצים בתיקייה מקומית כמו ./uploads מרגישה טבעית ומהירה. הקובץ נוחת באותה מכונה שמריצה את הקוד שלכם, והגשת הקובץ בחזרה היא רק עניין של הפניה לנתיב הנכון. זה עובד מצוין, עד שזה מפסיק לעבוד.

ברגע שאתם מציבים load balancer לפני שרת אפליקציה שני, אחסון מקומי הופך לבאג. משתמש מעלה תמונת פרופיל. ה-load balancer מפנה את הבקשה לשרת A, והקובץ נכתב לדיסק של שרת A. מאוחר יותר, אותו משתמש מבקש לצפות בתמונה, אך ה-load balancer שולח את הבקשה לשרת B. שרת B בודק את מערכת הקבצים שלו ולא מוצא דבר. הקובץ למעשה חסר. ניתן ליישם sticky sessions כדי לקשור משתמש לאותה מכונה, אך זהו תיקון שביר. אם שרת A עובר ריסטרט, פריסה מחדש (redeployed), או מוחלף על ידי מופע autoscaling, הנתונים נעלמים. בסביבות מבוססות קונטיינרים (containerized environments), דיסקים מקומיים הם אפילו יותר זמניים (ephemeral). מערכת הקבצים של Docker container נועדה להיות חד-פעמית. התייחסות אליה כאחסון קבוע היא דרך בטוחה לאבד נתוני משתמשים.

הפתרון הסטנדרטי הוא להפריד בין חישוב (compute) לאחסון (storage). שומרים על שרתי האפליקציה ללא מצב (stateless) ושולחים קבצים שהועלו לאחסון אובייקטים ייעודי כמו AWS S3 או Google Cloud Storage. שירותים אלו בנויים לעמידות (durability), פיזור גיאוגרפי וריבוי משתמשים (concurrency) עצום. שרת האפליקציה מטפל בבקשה, מאמת את ה-metadata, ואז מעביר את הבתים לתשתית שתוכננה במיוחד כדי להחזיק אותם.

Yet even this pattern creates a bottleneck if you implement it carelessly. Many teams start by having the browser upload the file to the backend, and then having the backend forward every single byte to object storage. If a user uploads a five-hundred megabyte video, your server becomes a middleman. It consumes bandwidth pulling the file in, then consumes more bandwidth pushing it out to S3. The connection stays open for the entire duration of the transfer. Slow uploads from users with poor network conditions can tie up server connections for minutes. Memory usage stays elevated if the server buffers the stream, and if you are running on metered hosting, you are paying twice for the same data transfer. Horizontal scaling does not solve this, because every additional server you add still gets stuck ferrying bytes it does not need to see.

Modern systems solve the problem by removing the backend from the data path entirely. Instead of accepting the file, the backend only accepts a request for permission to upload. The flow looks like this:

  • The browser asks the backend to initiate an upload, usually sending only the filename, file type, and intended purpose.
  • The backend authenticates the user, validates the request against business rules, and uses an SDK to generate a temporary presigned URL from the object storage provider.
  • The backend returns that URL to the browser. The URL is scoped to a specific bucket and key, valid for a short window such as five or fifteen minutes, and signed with a token that grants only the precise permissions needed.
  • The browser uploads the file directly to S3 or GCS using a standard PUT or POST. The bytes travel straight from the user’s device to