← All Articles ๐ŸŒ Performance Anti-Patterns High By Nishant Kumar Verma Updated

Synchronous File I/O in Node.js: How AI Code Blocks Your Event Loop

AI generates readFileSync and writeFileSync in async contexts. Learn why this freezes your server and how to switch to async I/O.

PERF-002

Quick Answer

Node.js runs on a single-threaded event loop. When AI-generated code calls fs.readFileSync() or fs.writeFileSync(), the entire server freezes - every other request waits until the file operation finishes. Replacing sync I/O with async equivalents (fs.promises.readFile() / fs.promises.writeFile()) allows the event loop to handle other requests while I/O is in progress.

Why Synchronous I/O Kills Node.js Performance

Node.js is fundamentally different from multi-threaded runtimes like Java or Python. It uses a single event loop to handle all incoming requests. When that loop is doing JavaScript execution, it cannot process any other events - HTTP requests, database callbacks, timers, or anything else. This is called blocking the event loop.

Asynchronous I/O operations like fs.readFile() are non-blocking: Node.js sends the read request to the operating system, registers a callback, and immediately returns to the event loop to handle other work. The OS notifies Node.js when the read completes, and the callback runs.

Synchronous I/O like fs.readFileSync() does the opposite: it tells Node.js to stop everything, wait for the file read to complete, then continue. During this wait - which might be 1ms for a small config file or 500ms for a large log file - your server handles zero other requests.

The Node.js guide Don't Block the Event Loop puts it plainly: while the event loop is blocked working for one client, it cannot handle requests from any other client. If every request does a 100ms synchronous read, one process can serve at most about 10 requests per second, however many arrive. The impact is non-linear: a brief block cascades into a backlog of waiting connections, each holding open a socket and memory.

How AI Tools Generate Sync I/O

AI code generators produce synchronous file operations in several common contexts. The simplest is reading a configuration file. Ask Cursor or Bolt to "read a JSON config file at startup" and you often get:

// โŒ BAD - Blocks the event loop on every request (PERF-002)
import fs from 'fs';
import path from 'path';

export async function getAppConfig() {
  // readFileSync blocks the entire server while reading
  const configPath = path.join(process.cwd(), 'config.json');
  const raw = fs.readFileSync(configPath, 'utf-8');
  return JSON.parse(raw);
}

// In an Express route or Next.js API handler:
export async function GET(req: Request) {
  const config = await getAppConfig(); // Called on every request - blocks every time
  return Response.json(config);
}

There are two problems here. First, readFileSync blocks the event loop. Second, the config is read from disk on every request rather than being cached at startup. Even if you fix the sync issue, re-reading a file on every request is wasteful.

// โœ… GOOD - Async I/O + startup caching (PERF-002 resolved)
import { readFile } from 'fs/promises';
import path from 'path';

let cachedConfig: AppConfig | null = null;

async function loadConfig(): Promise<AppConfig> {
  if (cachedConfig) return cachedConfig;
  const configPath = path.join(process.cwd(), 'config.json');
  const raw = await readFile(configPath, 'utf-8'); // non-blocking
  cachedConfig = JSON.parse(raw) as AppConfig;
  return cachedConfig;
}

export async function GET(req: Request) {
  const config = await loadConfig(); // async - event loop stays free
  return Response.json(config);
}

The async version uses fs/promises (per the Node.js docs, importable as fs/promises since Node.js 14; the same API has been stable as fs.promises since Node.js 11.14 and 10.17) and caches the result after the first load. The event loop is free to handle other requests while the file is being read.

Common Sync I/O Patterns in AI-Generated Code

Sync Pattern Async Replacement Notes
fs.readFileSync(path) await fs.promises.readFile(path) Most common - config files, templates
fs.writeFileSync(path, data) await fs.promises.writeFile(path, data) Log files, generated output, uploads
fs.existsSync(path) await fs.promises.access(path) File existence check before read
fs.mkdirSync(path) await fs.promises.mkdir(path, { recursive: true }) Creating upload or temp directories
fs.readdirSync(path) await fs.promises.readdir(path) Listing directory contents
fs.statSync(path) await fs.promises.stat(path) File size / metadata checks

Every synchronous fs.*Sync() method has a direct async equivalent in fs.promises.*. The migration is mechanical: add await, use the fs.promises namespace, and make the containing function async.

When Sync I/O Is Acceptable

There are a small number of legitimate use cases for synchronous file I/O in Node.js:

  1. Module initialization at startup: Code that runs once when the process starts (not per-request) can use sync I/O. require('./config.json') itself is synchronous. Reading a file synchronously in your top-level module initialization is generally fine.
  2. CLI tools: If your code is a command-line tool that processes one task and exits (not a long-running server), synchronous I/O is simpler and the blocking is not a problem.
  3. Test setup: Reading fixtures or test data synchronously in test setup code is acceptable since tests typically run sequentially.

The critical rule is: never use synchronous I/O in request handlers, route functions, middleware, or any code that runs while the server is serving traffic. If the sync call is inside an Express handler, a Next.js API route, a Supabase Edge Function, or a Vercel serverless function, it will block the event loop under load.

How to Find Sync I/O in Your Codebase

Sync I/O is easy to audit with a text search. Look for these function calls outside of module-level initialization code:

  • readFileSync
  • writeFileSync
  • existsSync
  • mkdirSync
  • readdirSync
  • statSync
  • appendFileSync
  • copyFileSync

For each match, determine whether it is inside a request handler. If it is, replace it with the async equivalent. VibeDoctor's Vibe Check flags readFileSync, writeFileSync, appendFileSync, copyFileSync and readdirSync calls inside async functions (PERF-002), with the file path and line number. It skips test, CLI and script folders. Free to sign up.

Sync I/O in request handlers is a classic cause of "mystery slowdowns", where server response times degrade under moderate concurrent load but look fine during single-user testing.

Observing the Impact with a Benchmark

The performance difference between sync and async I/O shows up under concurrency. The table below is a worked model, not a measurement: it assumes each read takes 5ms (a large file or a slow disk) and shows how long the last request in a burst waits. Async reads run on libuv's thread pool, which has 4 threads by default, so at most 4 reads run at once.

Method Concurrency 1 Concurrency 10 Concurrency 50
readFileSync ~5ms ~50ms (queued, server frozen) ~250ms (queued, server frozen)
fs.promises.readFile ~5ms ~15ms (4 at a time) ~65ms (4 at a time)
Cached (async, read once) <0.1ms <0.1ms <0.1ms

At concurrency 1 (single user), you cannot tell the difference. At concurrency 50, the last synchronous read waits about 4x longer, and for that whole time the server cannot answer any other request, even one that needs no file. With async reads the event loop stays free, and caching removes the disk from the request path entirely. This explains why vibe-coded apps often feel fast during development but slow down when real users arrive at the same time.

FAQ

Does this apply to Vercel and serverless functions?

Yes. In the classic serverless model, such as AWS Lambda, each concurrent request gets its own execution environment, so sync I/O there adds to execution time (and cost) rather than queuing other requests. Vercel's Fluid compute, enabled by default for new projects since April 23, 2025, lets multiple invocations share one function instance, so a blocking read stalls the other requests on that instance just as it would on a long-running Node.js server. Async I/O is the correct pattern on both.

Is require() synchronous? Should I use import() instead?

require() is synchronous, but it is only called at module initialization time (when the process starts), not per-request. This is acceptable. Dynamic require() inside a request handler is a problem - use dynamic import() (which returns a Promise) if you need to load a module conditionally at request time.

How does the Node.js event loop relate to worker threads?

Worker threads are separate Node.js instances that each have their own event loop. CPU-intensive tasks can be offloaded to worker threads so they do not block the main event loop. File I/O is already handled by libuv's thread pool under the hood when you use async methods - you do not need to manually move file operations to worker threads.

What about reading environment variables with process.env?

process.env reads from memory, not from disk - it is synchronous but does not involve I/O. Accessing process.env.MY_VAR is safe in any context. The issue is only with actual disk reads and writes via the fs module.

Will ESLint catch readFileSync in route handlers?

Not by default. ESLint's core no-sync rule is deprecated in favor of n/no-sync from eslint-plugin-n, and neither is in a recommended config. When turned on, they flag every *Sync call (with an option to allow those at the top level of a file), not only those in request handlers. VibeDoctor's PERF-002 check flags the five payload reads and writes listed above when they sit inside an async function.

Diagnose your codebase - free

VibeDoctor checks for PERF-002 and 148 other issues across 21 diagnostic areas - security, performance, code quality, and more.

SCAN MY APP →