Local File Handling through a Remote Browser

TL;DR

  • The remote browser can't see your local file system
  • If you need to handle file transfers, you'll need to build the plumbing yourself
  • But not if you use the Browserless CDP API

A tale as old as browser automation itself. Everything in your local script just works: page.setInputFiles('./resume.pdf') works like a charm, downloads land in ~/Downloads, and you move the ticket to Staged. Then you deploy it to the cloud, run the script, and that trivial upload and download becomes a headache.

The trap of the cloud filesystem

Running the headless browser in the cloud feels identical to doing it locally. It evaluates JavaScript in the exact same way, it navigates through pages waiting for the exact same conditions, and it gets the exact same CSS selectors. That’s the magic of remote infrastructure: you write once, deploy, and run everywhere. So, naturally, you write your code as if it were the local machine. And then the remote browser ≠ remote infrastructure bites you.

The remote browser runs over there, while your file is over here. They do not share a disk, and no amount of CDP command shenanigans is going to bridge that gap. ./resume.pdf is a path on your machine that the cloud browser just doesn’t have.

File handling stops being a trivial function call and becomes a logistics problem. And downloads? Chrome dutifully writes the PDF to some /tmp directory inside a container you cannot ever SSH into, fires no signal you can catch from the outside, and then the session ends and the container evaporates and your file with it.

Downloaded files after a session is done

Downloaded files after a session is done

There are workarounds, absolutely. Back in v1, you could profit from the workspace API for exactly these cases, and those files stuck around for 7 days on dedicated accounts. Nowadays, you’d do something much more sophisticated, like intercepting CDP calls and having the local client save the streamed binary data:

await cdp.send("Network.enable");
await cdp.send("Network.setRequestInterception", {
  patterns: [
    {
      urlPattern: "*",
      interceptionStage: "HeadersReceived",
    },
  ],
});

// Function to download file once the response was intercepted
const downloadFileFromInterceptedResponse = async (interceptionId, fileName) => {
  const { stream: streamHandle } = await cdp.send(
    "Network.takeResponseBodyForInterceptionAsStream",
    {
      interceptionId: interceptionId,
    },
  );
  const writer = fs.createWriteStream(`${fileName}`, { encoding: "base64" });
  while (true) {
    const read = await cdp.send("IO.read", {
      handle: streamHandle,
    });
    if (read.eof) break;
    writer.write(read.data);
  }
  // After file is saved, we need to abort the request so that the browser doesn't wait for the response.
  cdp.send("Network.continueInterceptedRequest", {
    interceptionId: interceptionId,
    errorReason: "Aborted",
  });
};

// Listen for intercepted events events
const downloadPromises = [];
await cdp.on("Network.requestIntercepted", async (event) => {
  if (event.isDownload) {
    // When event is a download we call our download function
    const fileName = event.request.url.split("/").pop();
    downloadPromises.push(
      downloadFileFromInterceptedResponse(event.interceptionId, fileName),
    );
  } else {
    await cdp.send("Network.continueInterceptedRequest", {
      interceptionId: event.interceptionId,
    });
  }
});

But here’s the thing: even with these workarounds, getting a file into or out of a remote browser is still an infrastructure problem you have to think about.

Or not really, as we built the solution right into our CDP API.

APIs as Painkillers

We made the bet that we always do, as with everything else we build: don't make the developer build the plumbing, offer them a painkiller.

We expanded our CDP API to include 2 new methods and an event to make file handling as smooth as possible.

Browserless.uploadFile

Browserless.uploadFile takes a CSS selector and an array of file objects, each with a name, content, and mimeType.We reconstruct it internally, browser-side, drop it onto your <input type="file"> selector, and fire the real input/change events, so the page automatically gets the file as if it were a regular page.setInputFiles call:

await page.goto("https://jimpl.com/");
await page.waitForSelector("input[type=file]");

const cdp = await page.createCDPSession();
const fileToUpload = {
  name: "image.png",
  content: fs.readFileSync("image.png").toString("base64"),
  mimeType: "image/png",
};

const result = await cdp.send("Browserless.uploadFile", {
  selector: "input[type=file]",
  files: [fileToUpload],
});

console.log(result); // { ok: true }
await browser.close();

Browserless.setDownloadEnabled and Browserless.fileDownloaded

These commands close the other half. Enable downloads, then listen for them. When Chrome finishes writing the file, the event hands it back to you as base64, with the name, mimeType, and size, no plumbing required:

await cdp.send("Browserless.setDownloadEnabled", { enabled: true });

cdp.on("Browserless.fileDownloaded", ({ filename, data }) => {
  const buffer = Buffer.from(data, "base64");
  fs.writeFileSync(path.join(process.cwd(), filename), buffer);
  console.log(`saved: ${filename} (${buffer.length} bytes)`);
});

await page.goto("https://scraping-sandbox.netlify.app/downloadsamples");
await page.click('a[href="/samples/sample.json"]');

await new Promise((res) => setTimeout(res, 3000));
await browser.close();

You’ll find yourself with a nice file in your working directory.

To wrap up

At Browserless, we craft things with the intention that other engineers don’t have to reinvent the wheel constantly, nor reinvent the filesystem just to upload a file.

We know that the second the script runs in the cloud and not your laptop, a pile of things that were free and easy to do start costing you time and money: tokens, sessions, and yes, files.
These new CDP events will really come in handy for the plug-and-play MCP server we’ve developed over the last few months.

Check it out to see what sets us apart from the competition. You can read about more technical aspects and learn how to connect to our MCP in our docs. It is open source, so you can run the MCP locally. And you can even use it without an Anthropic/LLM account, through the Browserless CLI.