JS-Datei-Uploads mit Web Workers und Streams beschleunigen
Dateien effizient hochzuladen ist für moderne Webanwendungen entscheidend. Herkömmliche Ansätze lesen häufig ganze Dateien in den Speicher ein, was die UI einfrieren lassen, übermäßig viel RAM verbrauchen und besonders bei großen Dateien zu einer schlechten Nutzererfahrung führen kann. Wenn Sie aufwendige Verarbeitung in Web Workers auslagern und Daten mit JavaScript Streams in handhabbaren Chunks streamen, bleibt der Haupt-Thread reaktionsfähig, selbst wenn Nutzerinnen und Nutzer Dateien mit mehreren Gigabyte hochladen.
Herausforderungen bei herkömmlichen Datei-Uploads
Ein verbreiteter, aber ineffizienter Ansatz umfasst:
- Das gesamte Objekt vom Typ
FilemitFileReaderin den Speicher einlesen. - Ein Objekt vom Typ
FormDatamit den Dateidaten erstellen. - Das Objekt
FormDataper POST-Anfrage mitfetchoderXMLHttpRequestsenden.
Das Einlesen ganzer Dateien in Anwendungspuffer kann große Speicherspitzen verursachen. Wird ein
Objekt vom Typ File direkt an FormData angehängt, ist dieses Einlesen nicht nötig:
Gewöhnliche Uploads laufen bereits asynchron ab. Worker helfen, wenn zusätzlich CPU-intensive
Verarbeitung nötig ist, nicht indem sie die Netzwerkbandbreite erhöhen.
Web Workers und Streams im Überblick
Web Workers
Mit Web Workers können Sie JavaScript-Code in Hintergrund-Threads ausführen, getrennt vom Haupt-Thread, der die UI verwaltet. Dadurch blockieren rechenintensive Aufgaben wie Hashing, Komprimierung oder das Aufteilen von Daten in Chunks für Uploads weder das Rendering noch das Scrollen oder Nutzereingaben, was zu einem flüssigeren Erlebnis während der Datei-Uploads führt.
JavaScript-Streams
Die Streams API ermöglicht es, Daten schrittweise in Chunks zu verarbeiten. Statt eine ganze Datei
in den Speicher zu laden, können Sie kleine Teile (in der Regel Objekte vom Typ
Uint8Array) lesen und verarbeiten, sobald sie verfügbar sind. Das senkt den Speicherverbrauch
drastisch und erlaubt es, Daten fast unmittelbar nach dem Lesen über das Netzwerk zu senden, wodurch
JavaScript-Streams für große Uploads nützlich werden.
Architekturüberblick
Ein paralleles Upload-System mit diesen Technologien umfasst typischerweise:
- Haupt-Thread: Übernimmt UI-Interaktionen (etwa Drag-and-drop), die Dateiauswahl und die
Anzeige des Fortschritts. Er übergibt Objekte vom Typ
Filean den Worker-Pool. - Worker-Pool: Eine Gruppe von Web Workers verwaltet die Aufgaben der Dateiverarbeitung. Jeder
verfügbare Worker erhält eine Referenz vom Typ
File. - Einzelner Worker: Liest die Datei mit der API
Blob.stream()oderFile.stream()Chunk für Chunk ein. Jeder Chunk wird anschließend per POST an den Upload-Endpunkt im Backend gesendet. Fortschrittsmeldungen (Prozentsatz abgeschlossen) und Statusaktualisierungen (Abschluss, Fehler) werden an den Haupt-Thread zurückgesendet. - Backend: Nimmt die Chunks entgegen und setzt sie wieder zur vollständigen Datei zusammen. Dabei kommen häufig Protokolle wie tus, Multipart-Uploads von Cloud-Storage (z. B. S3 Multipart Upload) oder eigene serverseitige Logik zum Einsatz.
Eine Datei streamen, ohne die UI einzufrieren
Die folgenden Beispiele zeigen ein minimales, aber praxistaugliches Muster für Chunk-Uploads mit einem Worker-Pool. Beachten Sie die enthaltene Fehlerbehandlung und die Aufräummechanismen.
Haupt-Thread (main.js)
Dieses Skript richtet den Worker-Pool ein und verarbeitet die Ereignisse des Datei-Inputs, wobei es
die Verarbeitung jeder Datei an den Pool delegiert. Setzen Sie die Definition von
WorkerPool weiter unten in diesem Artikel in main.js vor diesen Code und laden
Sie diese Datei nach diesem HTML. Liefern Sie beide Skripte vom selben Ursprung über localhost oder
HTTPS aus. Die folgenden Callbacks protokollieren den Fortschritt; eine produktive Oberfläche sollte
ihn sichtbar darstellen.
<label for="file-input">Files to upload</label>
<input type="file" id="file-input" multiple />
<button type="button" id="cancel-uploads">Cancel uploads</button>
<script src="main.js"></script>
// Assumes WorkerPool class is defined elsewhere (see below)
let pool = new WorkerPool('upload-worker.js')
const fileInput = document.querySelector('#file-input')
fileInput.addEventListener('change', (evt) => {
if (pool.closed) pool = new WorkerPool('upload-worker.js')
const files = Array.from(evt.target.files)
files.forEach((file) => {
console.log(`Queueing ${file.name} for upload...`)
pool.processFile(file, {
onProgress: (pct, msg) => updateProgressUI(file.name, pct, msg),
onComplete: (msg) => showSuccess(file.name, msg),
onError: (err) => showError(file.name, err),
})
})
fileInput.value = ''
})
function updateProgressUI(filename, pct, message) {
// Update your progress bar or UI element here
console.log(`${filename}: ${pct.toFixed(1)}% – ${message}`)
}
function showSuccess(filename, message) {
// Update UI to show completion
console.info(`${filename}: ${message}`)
}
function showError(filename, error) {
// Update UI to show error state
console.error(`${filename}: Upload failed - ${error}`)
}
document.getElementById('cancel-uploads').addEventListener('click', () => pool.terminate())
window.addEventListener('pagehide', () => {
pool.terminate()
})
Worker-Implementierung (upload-worker.js)
Dieser Worker fasst die unterschiedlich großen Stream-Chunks des Browsers zu Requests von 1 MiB
zusammen. Das Backend muss uploadId jeweils authentifizieren und autorisieren, Chunks anhand
ihres Index idempotent annehmen, Limits durchsetzen und erst nach der Validierung aller Chunks
abschließen. /upload akzeptiert die unten aufgeführten Multipart-Felder;
/complete-upload akzeptiert JSON mit uploadId und totalChunks. Beide müssen einen
erfolgreichen HTTP-Status zurückgeben. Diese eigenen Endpunkte werden in diesem Tutorial nicht
bereitgestellt, und Dateinamen dürfen niemals als ungeprüfte Speicherpfade behandelt werden. Dieses
Beispiel verfügt über Timeouts für Anfragen, aber weder über Wiederholungsversuche noch über eine
Wiederherstellung nach dem Neuladen; verwenden Sie ein gepflegtes, fortsetzbares Protokoll, wenn Sie
diese Garantien benötigen.
const CHUNK_SIZE = 1024 * 1024
async function* readChunks(file) {
const reader = file.stream().getReader()
let buffer = new Uint8Array(CHUNK_SIZE)
let used = 0
let finished = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
finished = true
break
}
let offset = 0
while (offset < value.length) {
const length = Math.min(CHUNK_SIZE - used, value.length - offset)
buffer.set(value.subarray(offset, offset + length), used)
used += length
offset += length
if (used === CHUNK_SIZE) {
yield buffer
buffer = new Uint8Array(CHUNK_SIZE)
used = 0
}
}
}
if (used > 0) yield buffer.subarray(0, used)
} finally {
if (!finished) await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
async function uploadChunk(chunk, filename, index, uploadId, totalChunks) {
const formData = new FormData()
// Send chunk index for server-side reassembly
formData.append('chunkIndex', index.toString())
// Send the actual chunk data as a Blob
formData.append('fileChunk', new Blob([chunk]), `${filename}.part${index}`)
formData.append('uploadId', uploadId)
formData.append('totalChunks', String(totalChunks))
// Replace '/upload' with your actual back-end endpoint
const res = await fetch('/upload', {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(60_000),
})
if (!res.ok) {
throw new Error(`Chunk rejected: HTTP ${res.status}`)
}
}
self.onmessage = async (event) => {
const file = event.data
try {
if (!(file instanceof File) || file.size === 0) throw new Error('Select a nonempty file')
const uploadId = crypto.randomUUID()
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
let index = 0
let uploaded = 0
for await (const chunk of readChunks(file)) {
await uploadChunk(chunk, file.name, index++, uploadId, totalChunks)
uploaded += chunk.length
self.postMessage({ type: 'progress', progress: uploaded / file.size * 100, message: 'Uploading.' })
}
const response = await fetch('/complete-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, totalChunks }),
signal: AbortSignal.timeout(60_000),
})
if (!response.ok) throw new Error('Finalization failed')
self.postMessage({ type: 'complete', message: 'Upload finished successfully.' })
} catch {
self.postMessage({ type: 'error', message: 'Upload failed. Please try again.' })
}
}
Warum die Datei im Worker nicht einmal vollständig einlesen?
Das vollständige Einlesen der Datei im Worker verhindert zwar, dass der Haupt-Thread blockiert wird, verbraucht aber weiterhin erheblichen Speicher im Worker-Thread selbst. Die Datei innerhalb des Workers Chunk für Chunk zu streamen, bietet mehrere Vorteile:
- Geringerer Speicherbedarf: Zu jedem Zeitpunkt befinden sich nur kleine Chunks im Speicher.
- Backpressure: Der nächste Anwendungs-Chunk wird erst angefordert, nachdem der aktuelle Upload abgeschlossen ist. Für Wiederholungsversuche und das Fortsetzen sind weiterhin ein Serverprotokoll und ein gespeicherter Stand der bestätigten Chunks erforderlich.
- Parallele Chunk-Uploads: Fortgeschrittene Implementierungen könnten mehrere Chunks gleichzeitig hochladen (das erhöht allerdings die Komplexität bei der Reihenfolge und der serverseitigen Verarbeitung).
Einen kleinen Worker-Pool bauen
Ein einzelner Worker kann dennoch zum Engpass werden, wenn Sie viele Dateien gleichzeitig
verarbeiten müssen. Ein Pool vom Typ WorkerPool begrenzt die gleichzeitigen Aufgaben und stellt
ausstehende Dateien in eine Queue. Beginnen Sie mit einem niedrigen Limit und messen Sie; die Anzahl
der CPUs ist keine Empfehlung für die Upload-Bandbreite. Dieser Pool bricht bei einem Fehler im
Worker-Skript oder bei der Serialisierung von Nachrichten alle Arbeiten ab, statt einen defekten
Worker weiterzuverwenden.
class WorkerPool {
constructor(script, size = Math.min(navigator.hardwareConcurrency || 2, 4)) {
if (!Number.isInteger(size) || size < 1 || size > 4) throw new Error('Use 1 to 4 workers')
this.closed = false
this.workers = []
this.idleWorkers = []
this.taskQueue = []
this.taskCallbacks = new Map() // Map task ID to callbacks
console.log(`Initializing WorkerPool with size ${size}`)
for (let i = 0; i < size; i++) {
const worker = new Worker(script)
worker.id = `worker_${i}`
// Handle messages from the worker
worker.onmessage = (e) => this.handleWorkerMessage(worker, e.data)
// Handle errors occurring within the worker itself
worker.onerror = (e) => this.handleWorkerError(worker, e)
worker.onmessageerror = (e) => this.handleWorkerError(worker, e)
this.workers.push(worker)
this.idleWorkers.push(worker)
}
}
generateTaskId() {
return crypto.randomUUID()
}
processFile(file, callbacks) {
if (this.closed) {
callbacks.onError?.('The upload pool is closed.')
return
}
const taskId = this.generateTaskId()
const task = { id: taskId, file }
this.taskCallbacks.set(taskId, callbacks)
const idleWorker = this.idleWorkers.pop()
if (idleWorker) {
// An idle worker is available, run the task immediately
this.runTask(idleWorker, task)
} else {
// All workers are busy, add task to the queue
this.taskQueue.push(task)
console.log(
`Worker pool busy. Queued task ${taskId} for ${file.name}. Queue size: ${this.taskQueue.length}`,
)
}
}
runTask(worker, task) {
console.log(`Assigning task ${task.id} (${task.file.name}) to ${worker.id}`)
worker.currentTask = task // Associate task metadata with the worker
// Send the file object to the worker to start processing
// For large files, consider if Transferable Objects are applicable/needed
try {
worker.postMessage(task.file)
} catch (error) {
this.handleWorkerError(worker, error)
}
}
handleWorkerMessage(worker, data) {
const task = worker.currentTask
if (!task) {
console.warn(`Received message from worker ${worker.id} without an assigned task.`)
return
}
const callbacks = this.taskCallbacks.get(task.id)
if (!callbacks) {
console.warn(`Received message for unknown or completed task ${task.id}`)
return // Task might have been cancelled or already completed/failed
}
// Process messages based on their type
switch (data.type) {
case 'progress':
if (callbacks.onProgress) callbacks.onProgress(data.progress, data.message)
break
case 'complete':
this.finishTask(worker, task.id, () => callbacks.onComplete?.(data.message))
break
case 'error':
this.finishTask(worker, task.id, () => callbacks.onError?.(data.message))
break
default:
console.warn(`Received unknown message type from worker ${worker.id}:`, data.type)
}
}
handleWorkerError(worker, errorEvent) {
errorEvent.preventDefault?.()
this.terminate('An upload worker failed. Select your files to try again.')
}
finishTask(worker, taskId, notify) {
// Detach completed work before user callbacks can cancel or enqueue more work.
worker.currentTask = null
this.taskCallbacks.delete(taskId)
try {
notify()
} finally {
// A callback may terminate the pool; never return a dead worker to it.
if (!this.closed) {
const nextTask = this.taskQueue.shift()
if (nextTask) this.runTask(worker, nextTask)
else this.idleWorkers.push(worker)
}
}
}
terminate(message = 'Upload canceled.') {
if (this.closed) return
this.closed = true
console.log('Terminating worker pool...')
this.workers.forEach((worker) => {
console.log(`Terminating worker ${worker.id}`)
worker.terminate()
})
// Clear internal state
this.workers = []
this.idleWorkers = []
this.taskQueue = []
const callbacks = [...this.taskCallbacks.values()]
this.taskCallbacks.clear()
const errors = []
for (const callback of callbacks) {
try {
callback.onError?.(message)
} catch (error) {
errors.push(error)
}
}
if (errors.length > 0) throw new AggregateError(errors, 'Upload cancellation callbacks failed.')
}
}
Browser-Kompatibilität
Web-APIs entwickeln sich weiter; prüfen Sie daher stets die Browser-Unterstützung für die Funktionen, auf die Sie sich verlassen.
Die Methode Blob.stream() wird von
File geerbt, sodass es sich dabei nicht um getrennte
Kompatibilitätsanforderungen handelt. Prüfen Sie außerdem Worker
und AbortSignal.timeout()
in den von Ihnen unterstützten Browsern. Dieses Beispiel sendet File per Structured
Clone; Unterstützung für übertragbare Streams ist nicht erforderlich.
Für Browser ohne Unterstützung für File.stream() müssen Sie möglicherweise auf einen Ansatz auf
Basis von FileReader zurückgreifen (gegebenenfalls innerhalb des Workers, um den Haupt-Thread
nicht zu blockieren, wobei weiterhin mehr Speicher verbraucht wird) oder etablierte Bibliotheken wie
tus-js-client oder Uppy verwenden, die sich um die Kompatibilität kümmern und Funktionen wie
Fortsetzbarkeit bieten.
Bewährte Verfahren für das Speichermanagement
- Anzahl der Worker begrenzen: Beginnen Sie mit einem niedrigen Limit und messen Sie das Verhalten von CPU, Speicher und Netzwerk. Zu viele Worker können zu übermäßigem Kontextwechsel und Speicher-Overhead führen.
- Worker beenden: Rufen Sie explizit
worker.terminate()oderpool.terminate()auf, wenn die Worker nicht mehr benötigt werden (z. B. nachdem alle Uploads abgeschlossen sind oder beim Entladen der Seite), um Ressourcen freizugeben. Verwenden Sie in Ihrer Anwendungslogik Blöcke mittry...finally, damit die Beendigung auch dann erfolgt, wenn während des Uploads Fehler auftreten. - Referenzen freigeben: Setzen Sie sowohl im Haupt-Thread als auch in den Workern Referenzen auf
große Objekte (etwa Objekte vom Typ
File,BloboderArrayBuffersowie Stream-Reader) auf null, sobald sie nicht mehr benötigt werden (reader = null,file = null,chunk = null), damit die Garbage Collection greifen kann. Stellen Sie sicher, dass Stream-Reader mitreader.releaseLock()freigegeben werden. Brechen Sie auch nicht abgeschlossene Streams ab; das Freigeben einer Sperre schließt den Stream nicht und bricht seinen Producer nicht ab. - Chunk-Größe: Wählen Sie eine sinnvolle Chunk-Größe (z. B. 1-10 MiB). Sehr kleine Chunks erhöhen den Netzwerk-Overhead (mehr HTTP-Anfragen pro Datei), während sehr große Chunks einen Teil der speichersparenden Vorteile des Streamings zunichtemachen.
- Speicher überwachen: Nutzen Sie während Entwicklung und Tests die Entwicklertools des Browsers (etwa das Panel Memory von Chrome oder das Werkzeug Memory von Firefox), um die Speichernutzung unter Last zu beobachten und mögliche Lecks zu finden.
Das Beispiel für den Haupt-Thread beendet den Pool bei Cancel uploads
oder pagehide und erstellt anschließend einen neuen Pool, wenn erneut Dateien
ausgewählt werden. Beenden Sie den Pool nicht unmittelbar, nachdem asynchrone Arbeit in die Queue
gestellt wurde, es sei denn, Sie wollen sie abbrechen. Serverseitige Ablauffristen müssen bereits
empfangene Bytes aufräumen.
Sicherheit und Ausfallsicherheit
- CORS: Konfigurieren Sie die Richtlinie für Cross-Origin Resource Sharing (CORS) Ihres
Upload-Endpunkts sorgfältig auf dem Server. Erlauben Sie nur die notwendigen HTTP-Methoden
(POST, gegebenenfalls OPTIONS für Preflight-Anfragen) sowie die Header, die Ihr gewähltes
Protokoll tatsächlich verwendet, und beschränken Sie die Ursprünge
(
Access-Control-Allow-Origin) auf die Domain Ihrer Anwendung. - Authentifizierung/Autorisierung: Sichern Sie Ihren Upload-Endpunkt ab. Stellen Sie bei
Chunk-Uploads sicher, dass jede Chunk-Anfrage authentifiziert und autorisiert wird. Mögliche
Verfahren sind sichere HTTP-only-Session-Cookies, Bearer-Token (JWTs) im Header
Authorizationoder das Erzeugen vorsignierter URLs für jeden Chunk oder die gesamte Upload-Sitzung (bei Cloud-Storage üblich). - Wiederholungsversuche: Netzwerkprobleme sind häufig. Implementieren Sie in Ihrer Funktion
uploadChunkeinen Mechanismus, der fehlgeschlagene Chunk-Uploads wiederholt. Verwenden Sie exponentielles Back-off (zunehmend längere Wartezeiten zwischen den Versuchen, z. B. 1 s, 2 s, 4 s), um Server und Netzwerk nicht zu überlasten. Brechen Sie die Wiederholungen nach einer angemessenen Anzahl von Versuchen ab (z. B. 3-5). - Abbruch: Geben Sie Nutzerinnen und Nutzern eine Möglichkeit, laufende Uploads abzubrechen.
Verwenden Sie dafür die API
AbortController. Erstellen Sie vor dem Start des Uploads eine Instanz vonAbortController, übergeben Sie derensignalan jede Anfrage mitfetchund rufen Siecontroller.abort()auf, wenn die Nutzerin oder der Nutzer abbricht. Achten Sie darauf, dass Ihre FehlerbehandlungAbortErrorabfängt.
Eine Instanz von AbortController gehört in denselben Worker wie die Anfrage. Ein Controller im
Haupt-Thread wird nicht automatisch mit diesem Worker geteilt. Dieses Beispiel stoppt stattdessen
alle aktiven Worker bei Cancel uploads; ein Abbruch
einzelner Dateien würde eine explizite Worker-Nachricht und eine definierte Vereinbarung zum
Entfernen aus der Queue erfordern.
Web Workers debuggen
Das Debuggen von Workern unterscheidet sich etwas vom Debuggen im Haupt-Thread:
- Browser-DevTools: Wählen Sie im Panel Sources
von Chrome den Worker im Bereich Threads aus, um
den Debugging-Kontext zu wechseln.
Öffnen Sie im Werkzeug Debugger von Firefox die
Quelldatei eines aktiven Workers. Beide Browser unterstützen Breakpoints, das Inspizieren von
Variablen und die Ausgabe von
console.logfür Worker; siehe Debugging von Worker-Threads. - Fehlerbehandlung: Eine robuste Kommunikation von Fehlern über
postMessage(wie in den Beispielen gezeigt) ist entscheidend, um Probleme innerhalb des Workers zu verstehen, denn ein direktestry...catchim Haupt-Thread fängt Fehler aus dem Worker nicht ab. Sorgen Sie dafür, dass Fehler im Worker explizit abgefangen und zurückgesendet werden.
Häufige Fallstricke
- Zu viele Worker: Für jede Datei einen neuen Worker zu starten, statt einen Pool zu verwenden, kann die Ressourcen des Systems (CPU und Speicher) überlasten.
- Stream-Sperren: Es wird vergessen, nach dem Ende des Lesevorgangs oder bei einem Fehler
reader.releaseLock()auf einem Reader vom TypReadableStreamDefaultReaderaufzurufen. Verwenden Sie immer einen Block mitfinallyfürreleaseLock()und brechen Sie einen nicht abgeschlossenen Stream ab, bevor Sie ihn freigeben. - Große Nachrichten-Payloads: Vermeiden Sie es, sehr große Datenobjekte mit
postMessagezwischen Haupt-Thread und Workern zu senden, da dies Overhead durch Serialisierung und Deserialisierung (bzw. Structured Cloning) verursacht. Prüfen Sie bei großen Binärdaten den Einsatz von Objekten vom TypTransferable(etwaArrayBuffer), um dort, wo es unterstützt wird und sinnvoll ist, effizientere Übertragungen ohne Kopieren zu erreichen. - Reihenfolge der Chunks: Es wird angenommen, dass der Server die Chunks genau in der gesendeten Reihenfolge erhält. Netzwerklatenz und parallele Anfragen können die Reihenfolge verändern. Senden Sie mit jedem Chunk immer einen Index oder einen Byte-Offset mit, damit der Server die Datei korrekt wieder zusammensetzen kann.
- Unbehandelte Fehler: Fehlende Blöcke mit
try...catchim Worker, insbesondere rund um asynchrone Operationen wie das Lesen von Streams (reader.read()) und Netzwerkanfragen (fetch), können im Worker zu stillen Fehlern oder unbehandelten Promise-Rejections führen.
Die wichtigsten Erkenntnisse
- Web Workers können CPU-intensive Dateiverarbeitung vom Haupt-Thread wegverlagern und halten die UI während der Uploads reaktionsfähig.
- JavaScript-Streams ermöglichen den effizienten Umgang mit großen Dateien, indem Daten in Chunks verarbeitet werden, was die Pufferung in der Anwendung reduziert. Fortsetzbarkeit ist eine davon getrennte Frage des Protokolls.
- Ein Worker-Pool begrenzt die gleichzeitige Verarbeitung und hilft dabei, den Ressourcenverbrauch bei mehreren gleichzeitigen Uploads zu kontrollieren.
- Robuste Fehlerbehandlung (einschließlich Wiederholungsversuchen im Netzwerk und Fehlerbehandlung
für Streams), sauberes Aufräumen von Ressourcen (
terminate,releaseLock), Sicherheitsaspekte (CORS, Authentifizierung) und Aufmerksamkeit für die Browser-Kompatibilität sind für produktionsreife Implementierungen unerlässlich.
Wenn Sie eine produktionsreife Lösung suchen, die Chunking, Fortsetzbarkeit, Wiederholungsversuche und parallele Uploads von Haus aus beherrscht, sollten Sie Bibliotheken wie Uppy mit seinen verschiedenen Upload-Plugins in Betracht ziehen oder Dienste prüfen, die auf den robusten Umgang mit Dateien ausgelegt sind. Der Dienst für Datei-Uploads von Transloadit vereint diese Konzepte für zuverlässige Uploads großer Dateien. Viel Spaß beim Uploaden!
