fs & Node: The Ultimate Async Guide (fs promises)

fs (file system) module is a much misunderstood part of node JS, especially the asynchronous versus the synchronous methods. This mega post will cover everything you need to know about fs and more! I include lot’s of architecture guides and potential gotchas (many of which have gotten me in the past).

fs stands for file system, so (almost) anything you can do to a file using your terminal or mouse can also be done with fs.

This article will start with basic file writes and move on to complex writes, file reads and file management. Please do read all the text between code examples as there are many many places you can be tripped up when using fs.

What can fs do?

Because fs is part of node JS on the server it has elevated privileges that allow it to access the file system. That said, if you find that certain file operations fail then please check the linux privileges of your node JS process / server app folder.

The fs module can do many things including:

  • Open files
  • Read files
  • Create and write to files
  • Update files
  • Delete files
  • Rename files

fs Callbacks, Async and General Notes

In this mammoth article we will cover the basic methods needed to read, write and manipulate files. Bear in mind that there are 2 variations of most file manipulation commands in fs, synchronous and asynchronous. Or async and sync for shorthand.

Important Developer PSA: You should always aim to use the asynchronous file manipulation methods. This is because node JS runs on a single thread, is non blocking and event based. Ie, node expects your code to not block the main thread unnecessarily, which is very important for the responsiveness of your server.

You will very rarely, if ever, need to use synchronous methods in fs so this guide focuses exclusively on the async methods.

Asynchronous methods are further sub divided into 3 versions – callback, promise or async based. These are all essentially the same thing, just different ways of writing code. If you’re not yet familiar with synchronous vs asynchronous programming then please click this to see my most excellent guide on it.

Personally, I always default to async await code. It’s easier to read, flows better and helps you avoid nasty problems like race conditions or cross scope variable assignment, bugs from which are very hard to diagnose.

In the first section (writing basic files) I will also show you the callback version of the sample code but future sections will only use the promise / await syntax. Just know that you can always revert to the callback versions by implementing the relevant structure (but why would you??!?!)

Basic File Writes with fs

Writing Basic Files – writeFile Function

In order to write a basic file in fs we use the writeFile method, which takes 4 parameters:

writeFile (filePath, text to write, encoding (optional, defaults to utf8), return function)

NOTE: This callback version is for reference only, I strongly advise you to use the promises / await styles in the next section!

The callback below is implemented by passing in a function as the last argument of the writeFile method. That function is executed when the tasks completes:

const fs = require("fs");

let name = "Grant iAmDev";

//option 1: without specifying the file encoding
fs.writeFile("person.txt", name, (err) => {
  if (err) {
    console.log(err);
  }
  console.log("File saved");
});

//option 2: explicitly specify some options such as file encoding
fs.writeFile("enc-person.txt", name, { encoding: "utf8" }, (err) => {
  if (err) {
    console.log(err);
  }
  console.log("File saved");
});

Gotcha 1: writeFile will replace the file if it already exists! There will be no warnings and no thrown errors to let you know!

Gotcha 2: Do not run this method on a file whilst it is being written to by another writeFile call. You should have a flag to lock the file, preventing simultaneous writes!

Developer note: If you’re storing high frequency incoming data, or large data chunks then you should not use this method. Instead opt to use the createWriteStream method described later on.

writeFile – Promise Await Version (Avoid Callbacks)

If you prefer to avoid callback hell you can instead use the promises module included in fs. Note that the import statement changes to “fs/promises”. Quite often I’ve been caught out when visual studio auto imports just “fs” and I’m left scratching my head as to why the await statement doesn’t work!

Obviously, the promise version takes one less parameter as you don’t need to specify a callback function. Also note that we always enclose the writeFile call in a try-catch statement. This is necessary as access to a file is never guaranteed so could be a significant source of app crashes. If you have server logs ensure that a crash like this always gets recorded in there.

import fs from 'fs/promises'

async function writeData(name) {
  try {
    return await fs.writeFile("person.txt", name, "utf8") //options can use the shorthand version here, just a string automatically assigns file encoding
  } catch (err) {
    console.error('Error occurred while writing file:', err)
  }
}

In order to call the above “writeData” function you would use the following line:

await writeData("Grant @ iAmDev")

This call will run asynchronously, without tying up your main thread, but will pause execution of your code until a result is received. This function call will not block code in other functions from running, and keeps your app / server responsive.

If there is no error thrown then you can assume that the operation was successful. HOWEVER, I find this code a bit problematic as I usually need to know explicitly if an operation failed or succeeded. The simplest version of this is to return a boolean indicating success or failure. This is the most basic version of this code and there are other things I may do, but those are beyond scope of this article:

async function writeBasicFilePromise() { //always return a boolean so you know if something failed or not
  try {
    await fsp.writeFile("person-promise.txt", textToWrite, "utf8")
    return true
  } catch (err) {
    console.error('Error occurred while writing file:', err)
    return false
  }
}

//call above function and look at returned "success" to determine code flow
const success = await writeBasicFilePromise()
console.log("File write successful? : " + success)

If you’re super fussy you may want to also assert that the file now exists and its content matches what you expect but I hardly ever find this to be necessary.

writeFile in fs – Complete List of Flags & Details

The options object passed in to writeFile allows you to specify filesystem flags that change the default behaviour when opening a file. Here’s a simple example:

await fsp.writeFile("person-promise.txt", textToWrite, 
  {
    flag: "ax" //causes a thrown error if the file already exists!
  }
)

The following flags are available for you to use – be careful to choose the right one!

  • 'a': Open file for writing (adding to the end of existing content) + the file is created if it does not exist (default)
  • 'ax': Like 'a' but fails if the file path already exists
  • 'a+': Open file for reading and writing. The file is created if it does not exist.
  • 'ax+': Like 'a+' but fails if the file path exists.
  • 'as': Open file for writing in synchronous mode (be careful not to tie up your main thread). File is created if it does not exist.
  • 'as+': Open file for reading and writing in synchronous mode. The file is created if it does not exist.
  • 'r': Open file for reading. An exception is thrown if the file does not exist so you need to catch that exception.
  • 'rs': Open file for reading in synchronous mode. An error is thrown if the file does not exist.
  • 'r+': Open file for reading and writing. Exception is thrown if the file does not exist.
  • 'rs+': Open file for reading and writing in synchronous mode. Instructs the operating system to bypass the local file system cache. This is useful for opening files on NFS mounts as it allows skipping the potentially stale local cache. It has a big impact on I/O performance so using this flag is not recommended unless it is really, really needed.
  • 'w': Open file for writing. The file is created (if it does not exist) or truncated (if it exists).
  • 'wx': Like 'w' but fails if the path exists.
  • 'w+': Open file for reading and writing. The file is created (if it does not exist) or truncated (if it exists).
  • 'wx+': Like 'w+' but fails if the path exists.

Add Content to File with fs (Append)

Now that we’ve covered basic file writes let’s look at how we add content to the end of a file. This is only really convenient if you want to add to the end of a file. Adding to the middle of a file is notoriously difficult and something you’d need to manage yourself.

Side note: If you ever find you need to write a line in the middle of a file then you should really revise your architecture / storage choices. Usually a structured database, of any kind, would be a much better choice.

We can add content to a file with the appendFile method:

const fs = require('node:fs/promises');

async function appendTextToFile() {
  try {
    const content = 'Extra content at end of file';
    await fs.appendFile('/Users/iamdev/data.txt', content);
  } catch (err) {
    console.log(err);
  }
}

appendTextToFile();

Note: You can also append to a file using the writeFile method and including the flag ‘a’.

Add Content to Start of a File with node fs (Prepend)

Unfortunately there is no simple way to add content to the start of a file using the standard fs module. However there are libraries that will do this for you such as prepend-file.

Another alternative is to read the content from your existing file into memory, insert your new data and write all that back to the same file. Watch out though, a large file will occupy a lot of memory and if you don’t garbage collect properly then you’ll have mega memory leaks!

Continuous File Writes in node fs

Many times your file writes will require insertion of multiple data blocks over a longer period of time. For this operation you will want to open up a stream that allows addition of data into a file over time, whilst avoiding the overhead of re-opening the same file.

This is achieved in fs with the createWriteStream method.

createWriteStream for Writing Large / Continuous Files in fs

This function is fairly complicated as you are moving into a level below high level writeFile. Therefore you have more control, but also more responsibility in terms of handling file closes, errors etc.

First let’s cover the basic createWriteStream method and all the arguments it can take (with explanations):

fs.createWriteStream (filePath, OPTIONS)

OPTIONS can include any of the following:
  options <Object>
    //Specify file encoding
    encoding <string> Default: 'utf8'
    
    //Should the file be autoclosed after write? If you set
    // to false then you must close the file yourself!
    autoClose <boolean> Default: true
    
    //An event telling our code if the file has been closed
    emitClose <boolean> Default: true
    
    //What location should this content be inserted to?
    // Takes an integer from 0-MAX_ALLOWED_INT
    start <integer>
    
    //highWaterMark is another way of saying buffer size before
    // actually writing to file
    highWaterMark <number> Default: 16384
    
    // File descriptor is flushed prior to closing. Defaults to false.
    flush <boolean>

Of particular note here is the highWaterMark option, aka: buffer size. This sets the amount of data to hold in memory until flushed to file. Note that you can get amazing memory leaks in the order of gigabytes if incorrectly specified for your use case. Whenever your server is running always use something like atop or htop to monitor resource usage!

Now let’s move onto a more substantial example. In the following we are trying to architect createWriteStream in an asynchronous manner (as it does not have a promise version included in fs promises). The code below defines an async function that takes some input, writes it to file and returns a boolean to indicate success.

const fs = require("fs");

async function streamTextToFile(text) {
  try {
    let writer = await fs.createWriteStream("person-stream.txt", {
      encoding: "utf8",
      highWaterMark: 16384,
      flags: "a" //open and append (don't overwrite file)
    })
    await writer.write(text)
    await writer.write("\r") //create a new line
    return true
  } catch (err) {
    console.error('Error occurred while writing file:', err)
    return false
  }
}

//call above function - don't forget to await this if calling from within another function!
await streamTextToFile("hello world!")

Important notes: The above function combines the creation of the write stream (createWriteStream) AND the actual writing process (writer.write). This is just to make it clear for you.

In reality you would separate creation of the write stream and pass that into your write function. Basically you should only be opening the write stream once unless you have good reason to do otherwise.

createWriteStream Events, Manual Close etc

As we’re operating at a lower level we can specify if we want file closes to be done automatically by the system or if we should handle them. To do this we do 2 things:

  1. Tell the createWriteStream method that we want to handle file close ourselves
  2. Implement the event listener to close the file
  3. Remove all listeners you may have added (avoid memory leaks!)
// call this function with -> await streamTextToFileHandleFileClose("my text")
async function streamTextToFileHandleFileClose(text) {
  // Open a file, turn off auto close, implement a file close function ourselves
  // and write to log on successful close
  try {
    let writer = await fs.createWriteStream("person-stream.txt", {
      encoding: "utf8",
      highWaterMark: 16384,
      autoClose: false,
      emitClose: true,
      flags: "a"
    })
    
    //add a listener with options such as open, close, ready etc
    writer.addListener("open", (fd) => {
      console.log("File opened: ")
      console.log(fd)
    })


    await writer.write(text)
    await writer.write("\r") //create a new line
    
    writer.removeAllListeners() // important to avoid memory leaks!
    // Close event below still fires though so you won't miss that

    writer.close(() => {
      console.log("File closed!")
    })
    
    return true
  } catch (err) {
    console.error('Error occurred while writing file:', err)
    return false
  }
}

There are many events you can listen for but don’t forget to unsubscribe to your listeners or you’ll get awful memory leaks!

One weird caveat: The final writer close event will be still be called even after you’ve removed all your listeners with the removeAllListeners method!

Reading files with fs

How to Use readFile in node fs

As with the writeFile function we looked at earlier the readFile function is much the same. Here’s some basic async await code:

const fs = require('node:fs/promises');


async function readAndPrintFile() {
  try {
    //print out the index.js file that contains your server code!
    const data = await fs.readFile('index.js', { encoding: 'utf8' });
    console.log(data);
  } catch (err) {
    console.log(err);
  }
}

await readAndPrintFile();

The above code will print out your index.js file, which should be your server entry point. CHange to any file in your directory as needed.

Note: This will read the whole file into memory. If you don’t have enough memory then your app will slow down at best, crash at worst! Obviously only use this for small files!

A better alternative is to use a stream to read files that are larger.

How to Use a Stream to Read a File in Node js

A stream in Node creates a pipeline between content and your application. This avoids having to read a whole file into memory and you can instead listen out for chunks coming from your file:


async function streamTextFromFile() {
  //NOTE: This function combines createReadStream AND reads the file.
  // in reality you should separate these 2, only creating the stream once!
  try {
    let reader = await fs.createReadStream("index.js", 'utf8')
    reader.on('error', function (error) {
      console.log(`error: ${error.message}`);
    })

    reader.on('data', (chunk) => {
      //NOTE: this outputs RAW hex data if you DON'T set the encoding above!
      console.log(chunk);
    })

  } catch (err) {
    console.error('Error occurred while stream reading file:', err)
    return false
  }
}

await streamTextFromFile()

If you don’t specify file encoding then you will receive RAW file output (in hex).

Leave a Comment