Intermediate

I/O Operations in JavaScript

Learn console output, reading stdin, and synchronous vs asynchronous file I/O in JavaScript with Node.js and Docker-ready examples

Input and output are how a program talks to the outside world - the terminal, the keyboard, and the filesystem. In the browser, JavaScript’s “I/O” is the DOM and the network. On the server, Node.js gives JavaScript full access to standard input, standard output, and the file system through built-in modules.

What makes I/O in JavaScript distinctive is its event-driven, non-blocking nature. Because JavaScript grew up in the browser - where blocking the main thread freezes the whole page - the language and Node.js favor asynchronous I/O. Reading a large file doesn’t stop everything; instead you get a promise (or a callback) that resolves when the data is ready. Node.js also offers synchronous versions for scripts and startup code, giving you both styles.

In this tutorial you’ll write to the console with several output methods, read a line of input from stdin, and read and write files using both the synchronous fs API and the asynchronous promise-based API. You’ll also see how JavaScript surfaces I/O errors.

Console Output

You’ve already seen console.log(). Node.js offers several more ways to write to the two standard streams: stdout for normal output and stderr for errors and diagnostics.

Create a file named io_operations.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Console output methods in Node.js

// Basic output to stdout
console.log("Standard output line");

// Multiple arguments are joined with spaces
console.log("Sum:", 2 + 3, "Done");

// printf-style format specifiers: %s string, %d integer, %f float
console.log("Name: %s, Age: %d, Score: %f", "Ada", 36, 99.5);

// Template literals interpolate expressions with ${...}
const lang = "JavaScript";
console.log(`Learning ${lang} is fun!`);

// Write without an automatic trailing newline
process.stdout.write("No newline here... ");
process.stdout.write("continued on same line\n");

// console.error writes to stderr, not stdout
console.error("This message goes to stderr");

// Objects are pretty-printed automatically
const user = { name: "Grace", role: "admin" };
console.log(user);

console.log() appends a newline and separates arguments with spaces, while process.stdout.write() gives you raw control with no formatting. The console.error() method behaves like console.log() but sends output to the stderr stream - useful for separating diagnostics from real results when piping output.

Reading Input from stdin

To read from the keyboard (standard input), Node.js provides the readline module, which reads input one line at a time. Because input arrives asynchronously, you supply a callback that runs once the line is available.

Create a file named read_input.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Reading a line from standard input
const readline = require('node:readline');

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

rl.question("What is your name? ", (name) => {
  console.log(`Hello, ${name}! Welcome to JavaScript I/O.`);
  rl.close();
});

The rl.question() method prints a prompt and waits for a line of input without blocking the event loop. When the user presses Enter, the callback fires with the typed text. You must call rl.close() to release stdin so the program can exit.

Synchronous File I/O

The fs (file system) module reads and writes files. Its synchronous methods - suffixed with Sync - block until the operation finishes and return the result directly. They’re convenient for simple scripts and setup code.

Create a file named file_io.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Synchronous file I/O with the built-in fs module
const fs = require('node:fs');

// Write a file (creates it or overwrites existing content)
const lines = ['Fortran - 1957', 'Lisp - 1958', 'COBOL - 1959'];
fs.writeFileSync('languages.txt', lines.join('\n') + '\n');
console.log('File written: languages.txt');

// Append a line without erasing the file
fs.appendFileSync('languages.txt', 'JavaScript - 1995\n');
console.log('Appended one line');

// Read the entire file back as a UTF-8 string
const content = fs.readFileSync('languages.txt', 'utf8');
console.log('--- File contents ---');
process.stdout.write(content);

// Split the text into individual lines
console.log('--- Line by line ---');
content.trimEnd().split('\n').forEach((line, i) => {
  console.log(`${i + 1}: ${line}`);
});

// Check whether a file exists
console.log('Exists:', fs.existsSync('languages.txt'));

Passing 'utf8' as the encoding makes readFileSync return a string; without it you’d get a raw Buffer of bytes. Note that writeFileSync overwrites the whole file, while appendFileSync adds to the end - a distinction that matters when building up log files.

Asynchronous File I/O and Error Handling

The idiomatic Node.js approach is non-blocking I/O. The fs/promises module returns promises, which pair naturally with async/await. This lets your program stay responsive while the operating system does the slow disk work. Wrapping the calls in try/catch handles I/O errors cleanly.

Create a file named async_file.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Asynchronous, non-blocking file I/O with promises
const fs = require('node:fs/promises');

async function main() {
  try {
    // Write asynchronously - execution pauses here without blocking
    await fs.writeFile('report.txt', 'Async I/O complete\n');

    // Read the file back asynchronously
    const data = await fs.readFile('report.txt', 'utf8');
    process.stdout.write(data);

    // Attempt to read a file that does not exist
    await fs.readFile('missing.txt', 'utf8');
  } catch (err) {
    // Failed I/O throws; err.code identifies the failure
    console.error(`I/O error: ${err.code}`);
  }
}

main();

When readFile cannot find missing.txt, it rejects the promise, await throws, and control jumps to the catch block. The error object’s code property (ENOENT = “Error NO ENTry”) gives a stable, machine-readable reason - far more useful for logic than parsing the human-readable message.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official Node.js Alpine image
docker pull node:22-alpine

# Run the console output example
docker run --rm -v $(pwd):/app -w /app node:22-alpine node io_operations.js

# Run the stdin example, piping input in
echo "Ada" | docker run --rm -i -v $(pwd):/app -w /app node:22-alpine node read_input.js

# Run the synchronous file I/O example
docker run --rm -v $(pwd):/app -w /app node:22-alpine node file_io.js

# Run the asynchronous file I/O example
docker run --rm -v $(pwd):/app -w /app node:22-alpine node async_file.js

The -v $(pwd):/app flag mounts your current directory into the container so files created by file_io.js and async_file.js appear on your host machine. The -i flag on the stdin example keeps input open so echo can feed the prompt.

Expected Output

Running io_operations.js:

Standard output line
Sum: 5 Done
Name: Ada, Age: 36, Score: 99.5
Learning JavaScript is fun!
No newline here... continued on same line
This message goes to stderr
{ name: 'Grace', role: 'admin' }

Running read_input.js with Ada as input:

What is your name? Hello, Ada! Welcome to JavaScript I/O.

Running file_io.js:

File written: languages.txt
Appended one line
--- File contents ---
Fortran - 1957
Lisp - 1958
COBOL - 1959
JavaScript - 1995
--- Line by line ---
1: Fortran - 1957
2: Lisp - 1958
3: COBOL - 1959
4: JavaScript - 1995
Exists: true

Running async_file.js:

Async I/O complete
I/O error: ENOENT

Key Concepts

  • Two output streams - console.log() writes to stdout while console.error() writes to stderr, letting you separate results from diagnostics when piping or redirecting.
  • process.stdout.write() gives raw output with no added newline or argument formatting, unlike console.log().
  • Synchronous vs asynchronous - fs.readFileSync() blocks until done; fs/promises with await is non-blocking and is the idiomatic Node.js choice for anything performance-sensitive.
  • Encoding matters - pass 'utf8' to file reads to get a string; omit it to get a raw Buffer of bytes.
  • Write vs append - writeFile overwrites the entire file, while appendFile adds to the end without erasing existing content.
  • Error codes over messages - I/O failures expose a stable err.code (like ENOENT) that’s reliable for program logic, unlike the human-readable message.
  • readline for input - reading stdin is callback- or promise-based because input arrives over time; remember to close() the interface so the program can exit.

Running Today

All examples can be run using Docker:

docker pull node:22-alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining