We Scanned 250 Node.js Repos for Blocking I/O. 76% Had It — and the Benchmarks Explain Why That Matters.

by Ko-Hsin Liang
  • nodejs
  • blocking-io
  • event-loop
  • performance
  • typescript
  • express
  • benchmarking
  • static-analysis
  • anti-pattern
  • empirical-study
  • autocannon

We Scanned 250 Node.js Repos for Blocking I/O. 76% Had It.

There’s a rule every Node.js developer learns early: don’t block the event loop. It’s in the docs, it’s in every conference talk, it’s probably written on a sticky note somewhere on your monitor.

So I got curious. How many codebases actually follow that rule?

I wrote a static analysis tool, pointed it at 250 public Node.js repositories — frameworks, CMS platforms, API servers, CLI tools, the works — and counted every synchronous I/O call. Then I built five Express servers that deliberately use blocking patterns, put them under load with 100 concurrent connections, and measured what happens.

The short version: 76% of repos have blocking I/O somewhere. Most of it is harmless — startup code, test files, build scripts. But about 7% sits in request-handling code paths. And the benchmarks show exactly why that 7% matters: execSync in a handler drops your throughput from 10,000 requests per second to 36. pbkdf2Sync causes timeouts and errors that the async version completely avoids.

Here’s the full breakdown.

30-minute action plan for engineering leads: Run grep -rn "Sync(" src/ --include="*.ts" --include="*.js" | grep -v node_modules | grep -v test on your server codebase. Any hits in route handlers or middleware are immediate priorities — especially execSync (280× throughput penalty) and pbkdf2Sync (causes cascading timeouts). Swap each for its async counterpart and await the result. Then add perf_hooks.monitorEventLoopDelay() to your production monitoring — if P99 stays above 50ms, you still have blocking somewhere. The fixes are usually one-line changes; the performance gains are measured in orders of magnitude.


What “blocking” actually means in Node.js

Node.js runs your JavaScript on a single thread. One event loop, one call stack. When you call fs.readFileSync(), that thread stops doing everything else until the file read completes. No new requests get handled. No callbacks fire. No timers execute. The entire server just… waits.

For a CLI tool or a build script, that’s fine. You’re the only user and you’re waiting for the result anyway.

But inside a request handler serving 100 concurrent users? That file read blocks all 100 of them. Each one has to wait their turn. Latency stacks up linearly with concurrency.

The async alternative — fs.readFile() or fs.promises.readFile() — hands the work to a thread pool and lets the event loop keep processing other requests while the I/O completes in the background. Same result, no queue.

The question isn’t whether blocking is bad. It’s how bad, and how often it shows up in real code.


Part 1: How common is blocking I/O in the wild?

How I ran the scan

I built an AST-based detector (adapted from Code Evolution Lab) that parses JavaScript and TypeScript files with Babel and walks the syntax tree looking for synchronous calls from four module families:

ModuleWhat it catchesExamples
fsSynchronous file operationsreadFileSync, writeFileSync, existsSync, statSync
child_processSynchronous process spawningexecSync, spawnSync, execFileSync
cryptoSynchronous cryptographic opspbkdf2Sync, scryptSync, randomFillSync
zlibSynchronous compressiongzipSync, deflateSync, brotliCompressSync

For each hit, the detector classifies where in the code it appears using ancestor analysis and file path heuristics:

  • request_path — inside an Express/Koa/Fastify route handler or middleware
  • startup_path — module-level initialization, constructor, top-level code
  • tooling_path — test files, build scripts, migrations, seeds
  • background_path — timer callbacks, event listeners, promise chains
  • unknown_path — couldn’t determine from static analysis alone

I ran this against 250 repos. Everything from Express and Next.js to Strapi, n8n, and Redwood.

The numbers

187,251 files scanned. 10,609 blocking calls found. 191 out of 250 repos had at least one.

That’s a 76.4% prevalence rate.

Here’s how the calls break down by type:

CategoryCountShare
Sync file operations (fs)9,60890.6%
Sync child process9028.5%
Sync compression (zlib)590.6%
Sync crypto400.4%

File system calls dominate. Nine out of ten blocking I/O instances are fs.*Sync methods.

Which specific calls?

The top ten, by frequency:

MethodCount
existsSync2,588
readFileSync2,405
writeFileSync1,542
execSync659
mkdirSync654
readdirSync633
statSync397
unlinkSync258
copyFileSync194
lstatSync145

existsSync is the single most common blocking call in the Node.js ecosystem. Makes sense — checking if a file exists is such a quick, “harmless” operation that developers reach for the sync version without thinking. And usually, it is harmless. Until it isn’t.

Where does the blocking code live?

This is the more interesting question. Finding readFileSync in a test file is very different from finding it in an Express route handler.

ContextCountShare
Tooling / test files2,03519.2%
Startup / initialization1,31012.4%
Request handling7166.8%
Background (timers, listeners)1491.4%
Unknown (unclassified)6,39960.3%

The unknown bucket is large because static analysis can only see so much. Without runtime tracing, you can’t always tell if a function is called from a route handler or from a startup script. But the 6.8% that we can confidently place in request paths — that’s 716 blocking calls sitting directly in the hot path, across some very well-known projects.

That 6.8% is a lower bound. The 6,399 unknowns could shift the picture significantly. Here’s a quick sensitivity analysis:

If X% of unknowns are request-pathEstimated request-path totalShare of all findings
0% (current confirmed)7166.8%
5%1,0369.8%
10%1,35612.8%
25%2,31621.8%

Even a conservative 5% assumption pushes the estimate close to 10%. The true number is unknowable without runtime tracing, but the range gives you a sense of worst-case exposure. If your codebase has blocking calls that the detector couldn’t classify, assume they’re guilty until proven innocent.

And tooling plus startup accounts for 31.5%. Those are generally fine. A readFileSync in your webpack config or in a beforeAll() test hook isn’t going to hurt anyone.

The biggest offenders

RepositoryBlocking calls
redwoodjs/redwood747
yarnpkg/berry583
vercel/next.js515
atom/atom435
n8n-io/n8n429
gatsbyjs/gatsby326
babel/babel318
evanw/esbuild259
payloadcms/payload240
Budibase/budibase217

Before you get alarmed — raw count doesn’t mean raw risk. Yarn Berry and Babel are build tools. Their blocking calls run in CLI context, not in a server. esbuild is a bundler. Atom is a desktop app.

But n8n is a workflow automation server. Payload is a CMS with an API layer. Strapi (174 calls, further down the list) is a headless CMS serving HTTP requests. Those are the ones where blocking I/O in the wrong place actually hurts real users.

What the severity breakdown tells us

I assigned severity based on context:

SeverityCountWhat it means
Critical99Blocking call inside a loop, inside a request handler
High1,697In a request handler or inside a loop
Medium5,468Unknown context — can’t rule out request path
Low3,345Startup, tooling, or background — generally safe

For reproducibility, here are the exact classification rules the detector applies (in precedence order):

SeverityRule
Criticalcontext = request_path AND inLoop = true
Highcontext = request_path, OR context = background_path AND inLoop, OR context = unknown_path AND inLoop
Mediumcontext = background_path, OR context = unknown_path (no loop)
Lowcontext = startup_path, OR context = tooling_path

Context itself is determined by a deterministic precedence chain: tooling file path → AST request-handler detection → request-path file hint → background callbacks (timers, listeners, promises) → startup/init patterns → unknown. The detector checks both AST ancestor analysis (e.g., is this call inside a function passed to app.get()?) and file-path heuristics (e.g., does the path contain /routes/, /middleware/, /test/, /scripts/?).

99 critical findings. That’s blocking I/O, in a loop, in a request handler. Each one is a latency multiplier under concurrent load. The kind of thing that works fine in development with one user and explodes in production with fifty.


Part 2: What does blocking I/O actually cost?

Numbers from a scan are one thing. Knowing that readFileSync exists in 2,405 places doesn’t tell you how much it hurts. So I built five benchmark scenarios and measured them.

The setup

Each scenario has two Express servers: a “bad” one that uses the synchronous call and a “good” one that does the same work asynchronously. Same logic, same response, different I/O strategy.

I hit each server with autocannon — 100 concurrent connections for 20 seconds. That’s enough to saturate the event loop and reveal the blocking penalty.

Environment: Node.js v24.11.0, Windows x64, everything running locally.

TC1: readFileSync in a request handler

The textbook example. The bad server reads a config file synchronously on every request. The good server uses fs.promises.readFile and caches the result.

MetricBlockingAsyncDifference
Throughput3,066 req/s9,826 req/s3.2x
P50 latency32ms9ms3.6x
P95 latency39ms13ms3.0x
P99 latency43ms14ms3.1x
Event loop delay (avg)16.4ms11.5ms1.4x
Event loop delay (max)61.9ms23.5ms2.6x
Errors00

A 3.2x throughput drop from a single readFileSync call. Not catastrophic, but significant. At 100 concurrent connections, you’re serving 6,760 fewer requests over a 20-second window. Scale that to a day of traffic and it adds up.

The latency numbers tell the story more clearly. Every request in the blocking version takes at least 32ms at the median — that’s time spent waiting in the event loop queue behind other file reads. The async version keeps the median at 9ms because the event loop stays free to pick up the next request immediately.

TC2: execSync in a request handler

This is the scary one. The bad server runs a shell command synchronously — simulating something like execSync('git log --oneline') or execSync('convert image.png ...') that sometimes shows up in webhook handlers or admin endpoints.

MetricBlockingAsyncDifference
Throughput36 req/s10,000 req/s280x
P50 latency6,725ms8ms841x
P95 latency16,177ms12ms1,348x
Avg latency9,014ms10ms901x
Event loop delay (avg)3,821ms14ms273x
Event loop delay (max)6,451ms1,359ms4.7x
Errors640

280x throughput difference. From ten thousand requests per second to thirty-six.

The blocking version’s median latency is 6.7 seconds. P95 is over 16 seconds. And 64 requests errored out entirely — the event loop was so jammed that connections timed out or the OS rejected them.

The async version? 8ms median, zero errors, 10,000 req/s. Same work, same shell command, but executed with execFile (async) so the event loop stays responsive.

execSync is the single most destructive blocking call you can put in a request handler. It spawns a child process and blocks the parent until it finishes. Every other connection queues behind it. Under real concurrent load, your server effectively becomes single-user.

TC3: pbkdf2Sync in authentication

Password hashing on a login endpoint. The bad server uses crypto.pbkdf2Sync with 1,000,000 iterations. The good server uses the async crypto.pbkdf2 (promisified), which offloads the CPU work to the thread pool.

MetricBlockingAsyncDifference
Throughput17 req/s18 req/s~1.1x
P50 latency13,027ms5,653ms2.3x
P95 latency13,028ms5,802ms2.2x
Avg latency10,924ms4,918ms2.2x
Event loop delay (avg)4,744ms15.5ms306x
Event loop delay (max)9,488ms24.6ms386x
Errors910
Timeouts260

The throughput numbers look deceptively similar — both are processing roughly 17-18 requests per second. That’s because the actual CPU work (1M PBKDF2 iterations) takes about the same time either way. You can’t speed up the math.

But look at everything else.

The blocking version caused 91 errors and 26 timeouts. The async version: zero. The event loop delay is the tell — 4,744ms average in the blocking case versus 15.5ms for async. That’s a 306x difference in how responsive the server is to other work. While pbkdf2Sync is grinding through a hash, nothing else moves. No health checks, no other routes, no keepalive pings. With the async version, the hash runs on the thread pool and the event loop stays free.

This is the pattern that takes down production servers during login spikes. The CPU work is unavoidable — you need the hash. But blocking the event loop with it is a choice, and it’s the wrong one.

A note on security: pbkdf2Sync in a request handler isn’t just a performance bug — it’s a DoS amplifier. An attacker doesn’t need to find a vulnerability; they just need to send a burst of login requests. Each one locks the event loop for seconds, starving every other connection. The async version doesn’t eliminate the CPU cost, but it keeps the server responsive enough to enforce rate limits, respond to health checks, and shed excess load. If you’re running CPU-bound crypto on an auth endpoint, pair the async call with request rate-limiting and consider offloading to a worker thread pool or a dedicated queue for sustained spikes.

TC4: writeFileSync in a request handler

Logging, audit trails, file uploads — writing data to disk in a handler is common. The bad server uses writeFileSync. The good server uses fs.promises.writeFile.

MetricBlockingAsyncDifference
Throughput3,169 req/s10,185 req/s3.2x
P50 latency29ms9ms3.2x
P95 latency40ms13ms3.1x
P99 latency44ms14ms3.1x
Event loop delay (avg)15.9ms12.2ms1.3x
Event loop delay (max)49ms19.9ms2.5x
Errors00

Almost identical profile to TC1. 3.2x throughput hit, ~3x latency inflation across all percentiles. File writes and file reads block the event loop in roughly the same way — which makes sense, since both are I/O operations that wait on the OS kernel.

The event loop delay max (49ms vs 20ms) shows the spiky nature of writes — disk I/O can stall briefly on buffer flushes, and in the sync case, the entire server stalls with it.

TC5: existsSync + statSync in a request handler

The subtlest one. The bad server checks if a file exists and reads its metadata synchronously — two blocking calls per request. The good server uses fs.promises.access and fs.promises.stat with in-memory caching.

MetricBlockingAsync + CacheDifference
Throughput5,966 req/s10,121 req/s1.7x
P50 latency16ms9ms1.8x
P95 latency20ms13ms1.5x
P99 latency21ms13ms1.6x
Event loop delay (avg)16.3ms11.9ms1.4x
Event loop delay (max)21.3ms20.8ms~1.0x
Errors00

The smallest gap of all five scenarios. existsSync and statSync are fast operations — the kernel usually has file metadata cached, so the actual block time is tiny. You still lose 1.7x throughput, and the latency is nearly doubled, but it’s not the catastrophe that execSync is.

That said, this is also the most common pattern. Remember: existsSync showed up 2,588 times in the scan — the single most frequent blocking call. A 1.7x penalty applied across thousands of codebases adds up to a lot of wasted capacity.


The full picture

ScenarioBad ThroughputGood ThroughputRatioBad P95Good P95Bad EL Max
TC1: readFileSync3,0669,8263.2x39ms13ms62ms
TC2: execSync3610,000280x16,177ms12ms6,451ms
TC3: pbkdf2Sync1718~1x*13,028ms5,802ms9,488ms
TC4: writeFileSync3,16910,1853.2x40ms13ms49ms
TC5: existsSync+statSync5,96610,1211.7x20ms13ms21ms

*TC3 throughput is similar because the CPU work dominates — but the blocking version caused 91 errors and 26 timeouts, while the async version had zero.

Throughput penalty by scenario (blocking vs async ratio)

xychart-beta
    title "Throughput Ratio (async ÷ blocking)"
    x-axis ["TC1 readFileSync", "TC2 execSync", "TC3 pbkdf2Sync", "TC4 writeFileSync", "TC5 existsSync"]
    y-axis "× slower" 0 --> 300
    bar [3.2, 280, 1.1, 3.2, 1.7]

TC2’s 280× bar dwarfs everything else — execSync in a handler is in a class of its own. TC3 looks mild by throughput alone, but caused 91 errors and 26 timeouts that the async version completely avoided.

Event loop max delay by scenario (ms)

xychart-beta
    title "Event Loop Max Delay (ms)"
    x-axis ["TC1", "TC2", "TC3", "TC4", "TC5"]
    y-axis "ms" 0 --> 10000
    bar [62, 6451, 9488, 49, 21]
    bar [24, 1359, 25, 20, 21]

Orange = blocking, blue = async. TC3 (pbkdf2Sync) peaks at 9.5 seconds — the event loop is frozen solid while the hash computes. TC2 (execSync) hits 6.5 seconds. The async versions stay under 1.4 seconds even in the worst case.

A few patterns jump out.

The severity spectrum is wide. existsSync costs you 1.7x. readFileSync and writeFileSync cost 3.2x. execSync costs 280x. Not all blocking calls are created equal, and treating them as equally dangerous would be a mistake.

Errors matter more than throughput for some patterns. TC3’s throughput is nearly identical between blocking and async, but the blocking version is broken — 91 errors, 26 timeouts, and event loop delays pushing 10 seconds. A throughput-only comparison would miss this entirely.

Event loop delay is the canary. In every scenario, the event loop delay metric showed the damage before latency percentiles did. If you’re monitoring one thing, monitor that. Node.js gives you perf_hooks.monitorEventLoopDelay() — use it.


When blocking is fine (and when it’s not)

Not every readFileSync needs to be fixed. The scan data helps draw the line.

Safe contexts (31.5% of findings):

  • Module initialization — reading a config file at startup, once, before the server listens
  • Build scripts and CLI tools — no concurrent users, no event loop to block
  • Test setup — beforeAll() hooks, fixture loading, assertion helpers

Dangerous contexts (6.8% of findings):

  • Express/Koa/Fastify route handlers and middleware
  • Anything called per-request under concurrent load
  • Timer or event callbacks that fire repeatedly

The grey zone (60.3%):

  • The “unknown” bucket — functions where static analysis couldn’t determine the call site
  • Potentially safe, potentially not. Needs manual review or runtime tracing to tell

The practical takeaway: if you’re writing a CLI tool, use readFileSync all you want. If you’re writing an HTTP server, reach for the async version by default. The performance data is clear — even “fast” sync operations like existsSync cost you 1.7x throughput under load.


How to find this in your own code

Three approaches, from fastest to most thorough.

grep for the obvious. Search for Sync( in your src/ directory. It’s crude but surprisingly effective. Most blocking calls end in “Sync” — readFileSync, execSync, pbkdf2Sync. If the hit is inside a file that handles HTTP requests, you’ve found a problem.

grep -rn "Sync(" src/ --include="*.ts" --include="*.js" | grep -v node_modules | grep -v test

Use static analysis. Tools like Code Evolution Lab walk your AST and classify each blocking call by context — is it in a handler? A startup script? A loop? They’ll flag the dangerous ones and leave the safe ones alone. Much less noise than a raw grep.

Monitor event loop delay in production. Add this to your Express app:

const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

// Log every 30 seconds
setInterval(() => {
  console.log(`Event loop P99: ${(h.percentile(99) / 1e6).toFixed(1)}ms`);
  h.reset();
}, 30_000);

If your P99 event loop delay is consistently above 50ms, you’ve got blocking somewhere. The scan finds the code; the monitor finds the impact.


The fix is usually one line

For most cases, the fix is mechanical:

// Before (blocking)
const data = fs.readFileSync('config.json', 'utf8');

// After (async)
const data = await fs.promises.readFile('config.json', 'utf8');
// Before (blocking)
const output = execSync('git rev-parse HEAD').toString();

// After (async)
const { stdout } = await execPromise('git rev-parse HEAD');
// Before (blocking)
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512');

// After (async)
const hash = await util.promisify(crypto.pbkdf2)(password, salt, 100000, 64, 'sha512');

The pattern is always the same: swap the sync call for its async counterpart and await the result. Your function needs to be async, which might mean updating some call sites. But the actual I/O logic stays identical.

For existsSync specifically, there’s an extra optimization: cache the result. File existence rarely changes between requests. A simple in-memory cache with a TTL turns a 1.7x penalty into a no-op.


Honest caveats

A few things that might affect how you read these numbers.

The scan’s unknown bucket is large. 60% of findings couldn’t be classified by context. Static analysis without runtime tracing has fundamental limits — if a function is called from both a startup path and a request path, the AST alone can’t tell you which matters more. The 6.8% request-path figure is a lower bound.

The benchmarks are synthetic. Real request handlers do more than just one blocking call — they parse JSON, query databases, render templates. The blocking I/O penalty is one component of total latency. In practice, the relative impact depends on what else the handler does.

The “good” versions aren’t always drop-in replacements. The async execFile in TC2 uses a different API than execSync. The cached existsSync replacement in TC5 requires cache invalidation logic. These are still easy changes, but they’re not purely mechanical.

Local benchmarks understate network effects. All tests ran on localhost. In production, the event loop blocking also delays reading from and writing to network sockets, which compounds the latency penalty.

Static analysis has false positives and false negatives. The detector matches any call to a known synchronous method name — but it can’t always tell how that code is reached at runtime. Common false positives include readFileSync inside a utility function that’s only called at startup, or existsSync in a helper that short-circuits before the server starts listening. The context classifier catches many of these (tooling files, module-init patterns), but not all.

On the false-negative side, the detector misses sync calls hidden behind wrappers (e.g., a custom loadConfig() that internally calls readFileSync), dynamic require() or import() that resolve to synchronous modules, and native addons that block without using a recognisable *Sync name. It also won’t catch third-party libraries that use synchronous I/O internally — only direct calls in your source.

I didn’t measure formal precision and recall because that would require ground-truth labelling across 187,000 files, which is out of scope for this study. As a rough estimate: the detector has high precision for the calls it does find (a readFileSync call is always a readFileSync call), moderate precision for context classification (the unknown bucket is the honest acknowledgment of that), and unknown recall for indirect or wrapped patterns. Treat the scan numbers as a reliable lower bound on prevalence, not an exhaustive inventory.


What this means for your codebase

If you’ve got a Node.js HTTP server, do this:

  1. Run a quick grep for Sync( in your request-handling code
  2. Check your event loop delay metrics — anything above 50ms P99 deserves investigation
  3. Prioritise fixes by severity: execSync and pbkdf2Sync first (280x and error-causing), then readFileSync/writeFileSync (3.2x), then existsSync (1.7x)
  4. Leave startup code alone — blocking at boot is fine and sometimes simpler

The 76% prevalence number sounds alarming, but most of it is in safe contexts. The dangerous 7% is what matters. And the benchmarks show exactly what “dangerous” looks like: 280x throughput drops, cascading timeouts, and event loops frozen for seconds at a time.

The event loop is Node’s biggest strength and its most fragile bottleneck. One execSync in the wrong place is all it takes.


Appendix: source code and data reference

All code, data, and results are in the empirical-study repository.

Static analysis (Step 1)

FileWhat it does
src/step1-repo-scan/detector/blocking-io-detector.tsAST detector — sync method lists, context classification, severity assignment
src/step1-repo-scan/detector/types.tsScanIssue, ScanResult, AggregatedResults interfaces
src/step1-repo-scan/detector/parser.tsBabel parser config with error recovery
src/step1-repo-scan/scanner.tsRepo cloning, file collection, scan orchestration
src/step1-repo-scan/aggregate-results.tsAggregation, breakdown tables, summary output

Benchmarks (Step 2)

FileWhat it does
src/step2-benchmarks/run-all.tsOrchestrator — starts servers, runs load tests, prints comparisons
src/step2-benchmarks/load-test/run-load-test.tsAutocannon wrapper with latency extraction and event loop monitoring
src/step2-benchmarks/load-test/event-loop-monitor.tsperf_hooks.monitorEventLoopDelay integration
src/step2-benchmarks/scenarios/tc1-readfilesync.tsTC1: readFileSync vs fs.promises.readFile + cache
src/step2-benchmarks/scenarios/tc2-execsync.tsTC2: execSync vs async execFile + cache
src/step2-benchmarks/scenarios/tc3-crypto-sync.tsTC3: pbkdf2Sync vs async pbkdf2 (1M iterations)
src/step2-benchmarks/scenarios/tc4-writefilesync.tsTC4: writeFileSync vs buffered async write
src/step2-benchmarks/scenarios/tc5-existssync.tsTC5: existsSync + statSync vs async + LRU cache

Result data

FileContents
results/scan-*.jsonRaw scan issues (per-file, per-call)
results/summary-*.jsonAggregated breakdowns by type, context, severity, method
results/bench-*.jsonBenchmark metrics: throughput, latency percentiles, event loop delay, errors
data/repo-samples.mdCurated list of ~250 public Node.js repos used in the scan

Built at Stack Insight.