NodeJSSync vs Async vs Promises API

Sync vs Async vs Promises API

Every fs operation comes in three forms: synchronous (readFileSync), callback (readFile(…, cb)), and promise (fs/promises). They do the same work but have very different consequences for your program's responsiveness. Choosing correctly is one of the most impactful decisions in a Node app.

The same read, three ways

JS
// SYNC — blocks the thread until done
import { readFileSync } from 'node:fs'
const a = readFileSync('f.txt', 'utf8')

// CALLBACK — non-blocking, error-first callback
import { readFile } from 'node:fs'
readFile('f.txt', 'utf8', (err, b) => { if (err) throw err; use(b) })

// PROMISE — non-blocking, awaitable  ← preferred
import { readFile as readFileP } from 'node:fs/promises'
const c = await readFileP('f.txt', 'utf8')
Side-by-side comparison

Sync

Callback

Promises

Blocks event loop

Yes

No

No

Error handling

try/catch

Error-first arg

try/catch (await)

Composes / chains

Trivially (but blocks)

Awkward (nesting)

Cleanly

Return value

Direct

Via callback

Via promise

Best for

Scripts, startup

Legacy, streams

App code

Why sync blocks everything
`…Sync` freezes the entire process
A synchronous `fs` call stops the single JavaScript thread until the disk responds. During that time **no** other request is served, **no** timer fires, **no** promise resolves. On a busy server, a few `readFileSync` calls in the request path can tank throughput for every concurrent user. This is the same trap as any [blocking code](/nodejs/blocking-vs-nonblocking).

JS
import { readFileSync } from 'node:fs'

// In a request handler this is poison — every other request waits:
app.get('/report', (req, res) => {
  const data = readFileSync('huge-report.csv', 'utf8')   // ✗ blocks the loop
  res.send(data)
})
When sync is actually fine
Sync shines for one-shot, startup, and CLI code
If the code runs **once before serving traffic** — loading a config file, reading a TLS certificate, requiring a module — blocking is harmless and the simpler sync form is *clearer*. The same is true for build scripts and CLIs, where there's no concurrent work to block. The rule isn't "never sync"; it's "never sync in a server's hot path".

JS
import { readFileSync } from 'node:fs'

// Startup config — runs once, nothing to block. Sync is fine and readable:
const config = JSON.parse(readFileSync('./config.json', 'utf8'))
const cert = readFileSync('./server.key')
startServer(config, cert)
Callback vs promises

Both are non-blocking; promises just read better and compose without nesting. Prefer fs/promises unless you have a specific reason (a tight streaming loop, or interop with callback-based code):

JS
// Callback — nests as steps grow
readFile('a.txt', 'utf8', (err, a) => {
  if (err) return done(err)
  writeFile('b.txt', a.toUpperCase(), (err) => {
    if (err) return done(err)
    done(null)
  })
})

// Promises — flat and try/catch-able
try {
  const a = await readFileP('a.txt', 'utf8')
  await writeFileP('b.txt', a.toUpperCase())
} catch (err) { done(err) }
The fs/promises advantage
  • Reads top-to-bottom like synchronous code, but never blocks the loop.

  • One try/catch covers a whole sequence — no repeated error-first checks.

  • Composes with Promise.all, for await, timeouts, and the patterns from Async Control-Flow Patterns.

  • Works with AbortSignal for cancellation: readFile(path, { signal }).

Decision guide

Context

Use

Server request handlers

fs/promises (await)

App initialization / startup

Sync is fine (runs once)

CLI tools & build scripts

Sync is fine (no concurrency)

Streaming large data

Streams (createReadStream) + callbacks

Bridging old callback libraries

Callback API or util.promisify

Next
Add to a file without overwriting it — perfect for logs: [Appending to Files](/nodejs/appending-files).