Handling Dropped Folders with the DataTransfer API
To read the contents of a folder a user drags onto your page, call webkitGetAsEntry() on each DataTransferItem inside the drop handler, then walk the returned FileSystemDirectoryEntry with a readEntries() pump that loops past the 100-item batch cap, flattening the tree into a File[] that carries each file’s relative path.
The plain DataTransfer.files list is no help here: when a directory is dropped it appears as a single zero-byte entry with the folder’s name, type === "", and no way to reach a single byte inside it. This article sits inside drag-and-drop file uploads within upload fundamentals and browser APIs, and it is the one acquisition path where the file list alone is a dead end. The drop-zone build covers routing items; this covers what happens after you have a directory entry in hand.
When to use this approach
- Users drag entire folders onto your drop zone — photo libraries, exported asset bundles, a
dist/directory, a scanned document set — and expect every nested file to arrive. - You need to preserve the relative structure, because the folder layout is information:
2024/raw/frame-0001.jpgmust not collapse into a bag of 247 identically shaped names. - You ship to Chromium, Firefox and Safari 15+, all of which implement
webkitGetAsEntry()andcreateReader()despite the vendor prefix. If a folder picker on click is acceptable instead of a drag, an<input webkitdirectory>is far less code.
Prerequisites
- A drop handler that calls
preventDefault()on bothdragoveranddrop. Without thedragovercancellation thedropevent never fires at all. - Access to
DragEvent.dataTransfer.items. Thefileslist cannot describe a directory. - TypeScript with
lib: ["DOM", "DOM.Iterable", "ES2022"]. TheFileSystemEntrycallback API is not in the standard DOM lib in a usable form, so the module below declares the four interfaces it needs. - A downstream consumer that accepts
{ file, path }pairs rather than a bareFileList— a queue, a checksum pass, or whatever issues your presigned URLs with AWS SDK v3.
How the entry API actually works
webkitGetAsEntry() does not hand you a path on the user’s disk. It creates an isolated filesystem whose root contains exactly the one item that was dropped, and returns the entry for that item. Everything you read afterwards is expressed relative to that synthetic root, which is why entry.fullPath for a nested file reads /photos/2024/a.jpg and never /Users/anna/Pictures/photos/2024/a.jpg. The browser is deliberately withholding the real location; you get structure, not provenance.
Two lifetime rules follow from that, and they pull in opposite directions. The DataTransferItemList is neutered the instant your handler returns control to the event loop, so every webkitGetAsEntry() call must happen synchronously in the handler — this is the same capture-before-await rule the drag-and-drop guide applies to getAsFile(). The entry objects themselves, however, outlive the handler indefinitely. Capture the roots in a synchronous loop, then take all the time you need walking them.
A FileSystemDirectoryEntry is a lazy handle, not a snapshot. Nothing is read from disk until you call createReader() and then readEntries(), and each call re-reads live state. A file the user deletes ten seconds after the drop will throw when you finally reach it.
Implementation
The module below is the whole traversal: promisified callbacks, a batch pump, a depth cap, a file cap, hidden-file filtering, abort support and progress reporting. It has no dependencies.
// folder-drop.ts — flatten a dropped directory tree into File objects.
/** The FileSystem entry API predates the standard DOM lib; these are the parts used here. */
interface FsEntry {
readonly isFile: boolean;
readonly isDirectory: boolean;
readonly name: string;
readonly fullPath: string;
}
interface FsFileEntry extends FsEntry {
readonly isFile: true;
file(success: (f: File) => void, failure: (e: DOMException) => void): void;
}
interface FsDirEntry extends FsEntry {
readonly isDirectory: true;
createReader(): FsDirReader;
}
interface FsDirReader {
readEntries(
success: (entries: FsEntry[]) => void,
failure: (e: DOMException) => void,
): void;
}
export interface DroppedFile {
file: File;
/** Path relative to the drop root, e.g. "photos/2024/a.jpg". */
path: string;
/** 1 for a file sitting directly inside the dropped folder. */
depth: number;
}
export interface WalkOptions {
maxDepth?: number;
maxFiles?: number;
includeHidden?: boolean;
signal?: AbortSignal;
onProgress?: (filesFound: number, currentDir: string) => void;
}
const DEFAULT_MAX_DEPTH = 8;
const DEFAULT_MAX_FILES = 5000;
/** Directory readers opened in parallel. Six keeps the main thread responsive. */
const READER_CONCURRENCY = 6;
const toFile = (entry: FsFileEntry): Promise<File> =>
new Promise((resolve, reject) => entry.file(resolve, reject));
/** readEntries() yields at most 100 entries per call; pump until it returns an empty batch. */
function readAllEntries(reader: FsDirReader): Promise<FsEntry[]> {
return new Promise((resolve, reject) => {
const all: FsEntry[] = [];
const pump = (): void => {
reader.readEntries((batch) => {
if (batch.length === 0) {
resolve(all);
return;
}
all.push(...batch);
pump();
}, reject);
};
pump();
});
}
/** Breadth-first walk of one dropped directory entry. */
export async function walkDirectory(
root: FsDirEntry,
options: WalkOptions = {},
): Promise<DroppedFile[]> {
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
const includeHidden = options.includeHidden ?? false;
const out: DroppedFile[] = [];
let frontier: Array<{ dir: FsDirEntry; depth: number }> = [{ dir: root, depth: 0 }];
while (frontier.length > 0 && out.length < maxFiles) {
options.signal?.throwIfAborted();
const next: Array<{ dir: FsDirEntry; depth: number }> = [];
for (let i = 0; i < frontier.length; i += READER_CONCURRENCY) {
const slice = frontier.slice(i, i + READER_CONCURRENCY);
const levels = await Promise.all(
slice.map(({ dir, depth }) =>
readAllEntries(dir.createReader())
.then((children) => ({ children, depth, path: dir.fullPath }))
.catch((error: DOMException) => {
console.warn(`[folder-drop] skipped ${dir.fullPath}: ${error.name}`);
return { children: [] as FsEntry[], depth, path: dir.fullPath };
}),
),
);
for (const level of levels) {
for (const child of level.children) {
if (!includeHidden && child.name.startsWith(".")) continue;
if (child.isDirectory) {
if (level.depth + 1 < maxDepth) {
next.push({ dir: child as FsDirEntry, depth: level.depth + 1 });
} else {
console.warn(`[folder-drop] depth cap ${maxDepth} reached at ${child.fullPath}`);
}
continue;
}
if (out.length >= maxFiles) break;
try {
const file = await toFile(child as FsFileEntry);
out.push({
file,
path: child.fullPath.replace(/^\//, ""),
depth: level.depth + 1,
});
} catch (error) {
console.warn(
`[folder-drop] unreadable ${child.fullPath}: ${(error as DOMException).name}`,
);
}
}
options.onProgress?.(out.length, level.path);
}
}
frontier = next;
}
return out;
}
export interface DropResult {
files: DroppedFile[];
truncated: boolean;
}
export async function flattenDrop(
dataTransfer: DataTransfer,
options: WalkOptions = {},
): Promise<DropResult> {
// Synchronous pass: `items` is neutered the moment this function awaits anything.
const roots: FsEntry[] = [];
const loose: File[] = [];
for (const item of Array.from(dataTransfer.items)) {
if (item.kind !== "file") continue;
const legacy = item as DataTransferItem & { webkitGetAsEntry?: () => FsEntry | null };
const entry =
typeof legacy.webkitGetAsEntry === "function" ? legacy.webkitGetAsEntry() : null;
if (entry) {
roots.push(entry);
} else {
const file = item.getAsFile();
if (file) loose.push(file);
}
}
const out: DroppedFile[] = loose.map((file) => ({ file, path: file.name, depth: 0 }));
for (const entry of roots) {
if (entry.isFile) {
out.push({ file: await toFile(entry as FsFileEntry), path: entry.name, depth: 0 });
} else {
out.push(...(await walkDirectory(entry as FsDirEntry, options)));
}
}
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
return { files: out.slice(0, maxFiles), truncated: out.length > maxFiles };
}
Wire it into the drop handler with a wall-clock ceiling, so a pathological tree cannot hang the tab forever:
import { flattenDrop } from "./folder-drop.js";
const zone = document.querySelector<HTMLElement>("#drop-zone");
zone?.addEventListener("dragover", (event) => event.preventDefault());
zone?.addEventListener("drop", (event: DragEvent) => {
event.preventDefault();
const transfer = event.dataTransfer;
if (!transfer) return;
const controller = new AbortController();
const deadline = setTimeout(
() => controller.abort(new DOMException("folder walk exceeded 30s", "TimeoutError")),
30_000,
);
// Called synchronously: the items pass runs before the first await inside flattenDrop.
void flattenDrop(transfer, {
maxDepth: 6,
maxFiles: 2000,
signal: controller.signal,
onProgress: (found, dir) => console.debug(`[folder-drop] ${found} file(s), in ${dir}`),
})
.then(({ files, truncated }) => {
console.log(`[folder-drop] flattened ${files.length} file(s), truncated=${truncated}`);
for (const { path, file } of files) console.log(` ${path} (${file.size} bytes)`);
})
.catch((error: unknown) => console.error("[folder-drop] failed", error))
.finally(() => clearTimeout(deadline));
});
Line-by-line on the critical parameters
webkitGetAsEntry()beforegetAsFile(). A dropped folder also produces aFile-shaped object, withsize === 0. The entry is the only positive signal that distinguishes it from a genuinely empty text file.READER_CONCURRENCY = 6. Reading directories one at a time makes a 400-folder tree feel broken; reading all of them at once queues hundreds of file-system operations behind the compositor. Six matches the browser’s own per-origin connection ceiling and keeps frame times under 16 ms in practice.maxDepth: 6. Depth is measured from the dropped folder, not the disk root. Six levels covers every realistic asset layout and stops a symlinked or mounted loop dead.maxFiles: 2000plustruncated. Returning a boolean instead of silently slicing lets you tell the user “we took the first 2,000 of your files” rather than losing 40,000 of them without a word.child.name.startsWith("."). macOS scatters.DS_Storethrough every folder and Windows addsdesktop.ini; both would otherwise become real uploads.signal.throwIfAborted()at the top of each level. The check is cheap and gives cancellation a bounded worst case of one directory level, matching how AbortController and timeouts bound the network side of an upload.
Once you hold DroppedFile[], each file is an ordinary File: read it with FileReader and ArrayBuffer, sniff its real type from magic bytes rather than trusting the extension, and hash it with Web Crypto if you plan to deduplicate.
Draining a directory past the 100-entry cap
readEntries() is the single most common source of silent data loss in folder drops. It is specified to return “some or all” of the remaining entries, and Chromium interprets that as at most 100 per call. Firefox and Safari also batch. A folder holding 247 files therefore needs four calls: 100, 100, 47, then an empty array that signals exhaustion. Call it once and you upload 100 files, report success, and nobody notices until an editor complains that half the shoot is missing.
Two further reader rules matter. The reader is stateful: it holds a cursor into the directory, so you must keep calling the same reader object. Calling createReader() again restarts from entry zero and duplicates the first 100 results. And you must not have two readEntries() calls outstanding on one reader at the same time — Chromium rejects the second with InvalidStateError. The pump above satisfies both by recursing only from inside the success callback.
Turning fullPath into a safe storage key
entry.fullPath is user-controlled text that you are about to concatenate into an object key. Treat it exactly as you would a path parameter from an HTTP request.
export function toObjectKey(uploadId: string, relativePath: string): string {
const segments = relativePath
.split("/")
.filter((segment) => segment.length > 0)
// macOS hands you decomposed Unicode: "café" arrives as "cafe" + U+0301.
.map((segment) => segment.normalize("NFC"));
if (segments.length === 0) throw new Error(`empty relative path from drop`);
for (const segment of segments) {
if (segment === "." || segment === "..") {
throw new Error(`illegal path segment "${segment}" in "${relativePath}"`);
}
// Spaces are fine — "My Photos" is a legal folder name and a legal key segment.
if (/[\u0000-\u001f\u007f\\]/u.test(segment)) {
throw new Error(`control character or backslash in segment "${segment}"`);
}
}
if (segments.join("/").length > 900) {
throw new Error(`key too long: S3 allows 1024 UTF-8 bytes including the prefix`);
}
return `u/${uploadId}/${segments.join("/")}`;
}
Normalising to NFC is not cosmetic. Without it a file named café.jpg on macOS is stored under a byte sequence that will never match the same name typed on Linux or Windows, so your metadata lookup silently misses — the same class of bug you avoid when you index file metadata in PostgreSQL with a canonical key column.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
maxDepth |
number | 8 |
Levels below the dropped folder that are traversed. Deeper directories are logged and skipped, not thrown on. |
maxFiles |
number | 5000 |
Hard ceiling on collected files. Reaching it stops the walk and sets truncated: true. |
includeHidden |
boolean | false |
When false, any entry whose name starts with . is skipped, folders included — so .git/ never enters the frontier. |
signal |
AbortSignal |
none | Checked once per directory level. Aborting rejects the promise with the signal’s reason. |
onProgress |
function | none | Called after each directory with the running file count and that directory’s path. |
READER_CONCURRENCY |
const | 6 |
Directory readers in flight at once. Raise to 12 on desktop-only tools; lower to 2 on low-end mobile. |
Configuration gotchas
TypeError: item.webkitGetAsEntry is not a function
Thrown when the item is not a real DataTransferItem — most often in a jsdom or Vitest environment where the drop event is synthesised from a plain object. It also appears if you call the method on a File from dataTransfer.files, where it does not exist. Fix: feature-detect with typeof legacy.webkitGetAsEntry === "function" and fall back to getAsFile(), exactly as flattenDrop does.
NotFoundError: A requested file or directory could not be found
Raised by entry.file() when the underlying file has moved, been renamed or been deleted between the drop and the read. On a large tree with a 30-second walk this is not rare — a photo application rewriting sidecar files while you traverse will trigger it. Fix: catch per file, log the path, continue. Never let one missing file reject the whole walk.
NotReadableError on network volumes and protected folders
Dropping a folder from an SMB share that goes offline, or from a macOS location covered by Full Disk Access, makes readEntries() fail with NotReadableError. The catch around readAllEntries degrades that directory to zero children so the rest of the tree still completes, and the warning line tells you which subtree was lost.
A dropped macOS bundle expands into tens of thousands of files
.app, .photoslibrary and .fcpbundle are ordinary directories wearing a costume. Dropping a 60 GB photo library hands you 180,000 entries and a tab that allocates several hundred megabytes of File objects before it finishes. The maxFiles ceiling and the truncated flag are the guard; pair them with a size check before you start, the same way you would when handling 500 MB uploads, and reject archives that expand pathologically the way a zip bomb check does server-side.
Two folders with the same name collide
Each dropped item gets its own isolated filesystem, so dropping ~/work/assets and ~/home/assets together yields two entries whose fullPath values both start /assets/. Flatten them naively and the second overwrites the first in storage. Fix: prefix each root with its index in the items list, or key by crypto.randomUUID() per root and keep the display path separate from the storage path.
Verification
Build a fixture with a known shape — 247 files in one directory is the number that exercises the batch pump three times and then once more for the empty batch:
// make-fixture.mjs — run with: node make-fixture.mjs
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
const root = "drop-fixture";
await mkdir(join(root, "2024", "raw"), { recursive: true });
for (let i = 0; i < 247; i += 1) {
const name = `frame-${String(i).padStart(4, "0")}.jpg`;
await writeFile(join(root, "2024", "raw", name), "x");
}
await writeFile(join(root, "notes.txt"), "top level");
await writeFile(join(root, ".DS_Store"), "hidden");
console.log("fixture ready: 249 files on disk, 248 visible to the walk");
Drop drop-fixture/ onto the zone and assert the flattened shape:
const { files, truncated } = await flattenDrop(transfer, { maxDepth: 6, maxFiles: 2000 });
console.assert(files.length === 248, `expected 248 files, got ${files.length}`);
console.assert(!truncated, "unexpected truncation at maxFiles");
console.assert(
files.every((entry) => !entry.path.startsWith("/")),
"a leading slash leaked into a relative path",
);
console.assert(
files.some((entry) => entry.path === "drop-fixture/2024/raw/frame-0246.jpg"),
"the 247th file was lost — the readEntries pump stopped early",
);
console.assert(
files.every((entry) => !entry.path.endsWith(".DS_Store")),
"hidden files were not filtered",
);
console.assert(
Math.max(...files.map((entry) => entry.depth)) === 3,
"expected a maximum depth of 3",
);
console.log(`verified ${files.length} files, deepest path ${files[files.length - 1].path}`);
The frame-0246.jpg assertion is the important one: it fails loudly on exactly the bug the 100-entry cap causes, which no smaller fixture will reveal. In DevTools, watch the Console filter [folder-drop] for the per-directory progress lines, and check the Memory panel — 2,000 File handles cost roughly 1 MB of JS heap, while their contents are not resident until you read them. Feed the result into your upload queue and surface counts through your progress layer, where accurate time-remaining estimates need the total byte size you can now sum in one pass.
Frequently Asked Questions
Why does my dropped folder arrive as a single empty file?
DataTransfer.files represents a directory as one entry with size === 0 and type === "" — the folder’s name with nothing behind it. Reach the contents by calling webkitGetAsEntry() on the matching DataTransferItem and recursing with createReader(); the entry’s isDirectory flag is the only reliable way to tell that phantom apart from a genuinely empty file.
Why does my traversal miss files in large folders?
readEntries() returns at most 100 entries per call and signals exhaustion only with an empty array. A folder of 247 files needs four calls. Loop until you get [], always reusing the same reader object — calling createReader() again rewinds the cursor and duplicates the first batch.
Why does webkitGetAsEntry() return null after I await something?
The DataTransferItemList is neutered as soon as the drop handler yields to the event loop. Collect every entry in a synchronous loop at the top of the handler; the FileSystemEntry objects you captured stay valid for as long as you need them, even though the list that produced them does not.
Can I get the user’s real folder path on disk?
No, and no browser will give it to you. fullPath is relative to a synthetic root containing only the dropped item, so you see /photos/2024/a.jpg rather than anything under the home directory. If you need a stable server-side identity for the tree, generate it yourself and send it alongside the files, ideally as part of the same idempotency key scheme your retries already use.
Does the same code handle a pasted folder?
No. The clipboard never carries directory entries — a paste yields file items only, which is why pasting images from the clipboard needs no traversal at all. Route both sources into the same { file, path } consumer and the difference disappears downstream.