basic-ftp
basic-ftp is an FTP and FTPS client for Node.js with a promise-based API and zero runtime dependencies. You create a Client, call access() with host, credentials and secure: true for FTP over TLS, then use methods like list(), uploadFrom(), downloadTo(), ensureDir() and uploadFromDir() that read and write local paths or streams. It is written in TypeScript and ships type declarations. Its own README opens with an advisory telling you to use HTTPS or SFTP instead if you have the choice, because FTP itself offers no security and has reliability quirks. Active mode is not supported; passive mode only, including over IPv6.
The best FTP client on npm and the one to reach for when FTP is forced on you: small, typed, dependency-free, and unusually careful about protocol-level attacks. Just do not read that as an endorsement of FTP, and keep it patched rather than pinned.
Use it if
- A partner, bank, or legacy appliance only exposes FTP or FTPS and you genuinely cannot move them to SFTP or HTTPS
- You need whole-directory operations (uploadFromDir, downloadToDir, ensureDir, clearWorkingDir) rather than hand-rolling recursive walks over raw FTP commands
- You want the transfer client to add nothing to your dependency tree: it has zero dependencies and installs about 9 KB gzipped
- You are on TypeScript and want typed FileInfo results and typed access options instead of callback-era FTP libraries with hand-written type stubs
- You have any choice of protocol: the maintainer's own advisory says prefer HTTPS or SFTP, and plain FTP sends credentials and data in the clear
- You need parallel transfers on one connection: the FTP protocol does not allow concurrent requests, so throughput means running a pool of Client instances and their connections, which many servers cap
- Your server needs active mode, or serves a listing format that is not MLSD, Unix, or DOS: the parser covers those three and you supply your own parseList function otherwise
- You cannot keep the dependency current: 5.2.0 through 5.3.1 were a run of security fixes (control character injection in paths, unbounded listing and control responses, unsafe filenames in downloadToDir), so an old pin is a real exposure rather than a stale-version nag
- You want resumable transfers handled for you: resume is manual, using size() plus appendFrom with localStart for uploads and a startAt offset for downloads
Setup reality
npm install basic-ftp and there is nothing else to install, no native build, no peer dependency. The friction is the protocol, not the package. Most real debugging starts with client.ftp.verbose = true so you can read the actual command and response pairs, because FTP servers disagree about almost everything. Since 6.0.0 the client refuses a server that redirects transfers to a different host, which blocks FTP bounce attacks but will break you against the rare server that legitimately does it until you pass allowSeparateTransferHost: true. Implicit FTPS needs secure: "implicit" and port 990, and 6.0.2 specifically fixed TLS session resumption against the Node.js releases carrying the CVE-2026-48934 fix. Note that a large share of the weekly download count arrives transitively through get-uri, which pins the 5.x range and sits under pac-proxy-agent and proxy-agent, so those numbers are not 26 million people choosing 6.x.
Patterns
Connect over FTPS and close cleanlyconnect-ftps
import { Client } from "basic-ftp";
const client = new Client(30_000);
try {
await client.access({
host: "ftp.example.com",
user: "deploy",
password: process.env.FTP_PASSWORD,
secure: true,
});
console.log(await client.pwd());
} finally {
client.close();
}secure: true is explicit FTPS on port 21. Use secure: "implicit" with port 990 for legacy implicit FTPS. Without secure, credentials cross the wire in plaintext.
List a remote directorylist-directory
const files = await client.list("/incoming");
for (const f of files) {
console.log(f.isDirectory ? "dir " : "file", f.name, f.size);
}Only MLSD, Unix, and DOS listing formats are parsed. Against an odd server you assign your own function to client.parseList rather than patching the library.
Upload a local fileupload-file
await client.ensureDir("/incoming/2026-08");
await client.uploadFrom("./build/report.csv", "report.csv");ensureDir creates every missing directory and leaves the working directory there, so the remote path in uploadFrom is relative to it afterwards.
Download to a local path or streamdownload-file
await client.downloadTo("./tmp/report.csv", "/outgoing/report.csv");
// or straight into a stream
import { createWriteStream } from "node:fs";
await client.downloadTo(createWriteStream("./tmp/copy.csv"), "/outgoing/report.csv");An existing local file is overwritten. If you pass a stream, basic-ftp does not close it for you when the transfer is part of a longer pipeline.
Push and pull whole directoriessync-directory
// upload the contents of a local folder
await client.uploadFromDir("./dist", "/var/www/html");
// pull a remote folder down
await client.downloadToDir("./backup", "/var/www/html");uploadFromDir overwrites files with matching names and reuses directories but never deletes anything remote; call clearWorkingDir() first if you want a clean replace.
Report transfer progresstrack-progress
client.trackProgress((info) => {
console.log(info.type, info.name, info.bytes, "of session", info.bytesOverall);
});
await client.uploadFrom("./big.tar.gz", "big.tar.gz");
client.trackProgress(); // stop reportingbytesOverall counts every transfer since the last trackProgress call, so re-registering the handler is also how you reset the running total.
Resume an interrupted uploadresume-upload
const already = await client.size("big.tar.gz").catch(() => 0);
if (already > 0) {
await client.appendFrom("./big.tar.gz", "big.tar.gz", { localStart: already });
} else {
await client.uploadFrom("./big.tar.gz", "big.tar.gz");
}size() throws if the remote file is absent, hence the catch. Since 6.1.0 an upload whose local file changes mid-read throws instead of reporting a short upload as success.
Resume an interrupted downloadresume-download
import { statSync, existsSync } from "node:fs";
const path = "./tmp/big.tar.gz";
const offset = existsSync(path) ? statSync(path).size : 0;
await client.downloadTo(path, "big.tar.gz", offset);The third argument is the remote start offset and it is applied to the local destination too, so the partial file is appended to rather than truncated.
Tell a server rejection apart from a dead connectionerror-handling
import { FTPError } from "basic-ftp";
try {
await client.remove("/outgoing/missing.csv");
} catch (err) {
if (err instanceof FTPError) {
console.warn("server said no:", err.code, err.message);
} else {
console.error("connection lost, must reconnect");
await client.access(options);
}
}An FTPError leaves the connection usable. A timeout or socket error closes the client, and every later call fails until you call access() again; client.closed tells you which state you are in.
See the raw FTP conversationverbose-logging
client.ftp.verbose = true;
// or send it to your own logger instead of the console
client.ftp.log = (message) => logger.debug({ ftp: message }, "ftp");client.ftp.log is called whether or not verbose is true, so overriding it is the way to keep FTP chatter out of stdout while still recording it.
Opt back into a separate transfer hostallow-transfer-host
const client = new Client(30_000, { allowSeparateTransferHost: true });Off by default since 6.0.0 because a server redirecting transfers to another IP is the FTP bounce attack pattern. Only enable it for a specific server you trust that genuinely requires it.
Run many transfers without breaking the connectionsequential-transfers
// one connection, strictly sequential
for (const file of files) {
await client.uploadFrom(file.local, file.remote);
}
// concurrency means one Client per worker
const clients = await Promise.all(
[0, 1, 2].map(async () => {
const c = new Client(30_000);
await c.access(options);
return c;
}),
);Never fire Promise.all over one Client: FTP allows a single request at a time on a control connection, and overlapping calls corrupt the response stream.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ssh2-sftp-client | npm | The other side speaks SSH, which is what you should be asking for before writing any FTP code. |
| ssh2 | npm | You need the raw SSH connection for exec or port forwarding as well as file transfer. |
| webdav | npm | The remote store is Nextcloud, SharePoint, or another HTTP-based file server rather than FTP. |