← Engineering

The bug that wasn't where we thought it was: debugging EBADF on Vercel

Building a real headless-Chrome WCAG scanner as a Vercel serverless function, and the three-layer debugging trail (Chromium extraction, sync vs. async file reads, and a webpack asset rule) that finally fixed a production-only EBADF.

We were shipping 3Stone Shield API - a real WCAG 2.1 AA accessibility scanner: headless Chrome via puppeteer-core, the @sparticuz/chromiumserverless build, and axe-core (the same engine Lighthouse uses) injected into the page and run for real. Every static check passed. The moment it hit production traffic on Vercel, every single request came back:

{"error":"EBADF: bad file descriptor, read"}

Here's the trail it took to actually fix it - three attempts, three real pieces of evidence, and a root cause that turned out to be nowhere near where we started looking.

Attempt 1: it has to be Chromium's fault

This was the obvious suspect. @sparticuz/chromium ships a compressed Chromium binary and extracts it into /tmp on first use. Reading its own source, the extraction logic does a plain existsSync(output) check, then opens a write stream if that comes back false - no locking. Vercel's function concurrency model can route more than one request to the same warm instance at once. Two requests landing close together could both see "not extracted yet," both start writing into the same /tmp files, and one's read fails when the other invalidates its file descriptor mid-write.

That's a real bug, and a real fix: cache the in-flight extraction promise at module scope so every request on one instance awaits the same extraction instead of racing to start its own. We shipped it, redeployed, and tested again.

Still EBADF. And critically - it failed on a clean, single, non-concurrent request, not just under load. That single fact ruled out the whole theory. A race condition doesn't reproduce deterministically on the first request to a fresh instance.

Stop guessing, start logging

Two rounds of plausible-sounding hypotheses hadn't fixed anything. Before trying a third, we added real instrumentation: a timestamped log line before and after every step - resolving Chromium's executable path, launching the browser, navigating, injecting axe-core, running the scan - plus the full stack trace on failure, logged server-side only (never in the API response). Redeployed, ran one real scan, pulled the actual production logs.

[shield scan] resolved executable path: /tmp/chromium (+3305ms)
[shield scan] launching browser (+3306ms)
[shield scan] browser launched (+3429ms)
[shield scan] navigating (+3465ms)
[shield scan] navigation complete (+4420ms)
[shield scan] injecting axe-core (+4423ms)
[shield scan] failed during "injecting axe-core" (+4423ms):
Error: EBADF: bad file descriptor, read
    at j (.next/server/chunks/1185.js:12:2413)

Chromium was never the problem. Extraction, launch, and a real page navigation all completed fine in under a second and a half. The failure was one step later - reading axe-core's minified bundle off disk to inject it into the page, via a plain readFileSync.

Attempt 2: bundling, sync vs. async, and a stranger error

Next reasonable guess: Next.js's build was relocating the file the same way it can with bundled dependencies, so serverExternalPackages (the same fix that made Chromium's own binary work) should apply here too. We added it. Redeployed. Exact same failure, same line, same stack trace - zero effect. The file wasn't being bundled away; something else was wrong with reading it at all.

Next theory, closer to the evidence: readFileSync is a synchronous read. Chromium's own file access - the thing that had just worked - was entirely stream-based and async. We switched to node:fs/promises's readFile and awaited it properly. Redeployed. Got a genuinely different error this time - real progress, but not a fix:

TypeError: The "path" argument must be of type string
or an instance of Buffer or URL. Received type number (17406)

Same file, same code path, a completely different failure mode depending on whether the read was sync or async. That's not what a missing or misplaced file looks like - it's what a filesystem that doesn't behave like a normal filesystem looks like. Vercel's output-file-tracing machinery (which decides what actually ships inside the deployed function, separate from webpack bundling) was giving us a path that worked for some operations and not others.

The actual fix: stop touching the filesystem for this at all

axe-core's minified bundle is static content. It doesn't change per request, per deploy, or per anything - there was never a real reason to read it off a filesystem at request time in the first place. The fix was to stop trying to make that read work, and inline the file into the JavaScript bundle at build time instead, via a one-line webpack rule:

webpack(config) {
  config.module.rules.push({
    test: /axe-core[\\/]axe\.min\.js$/,
    type: "asset/source",
  });
  return config;
}

Then a plain static import instead of a runtime file read:import axeCoreSource from "axe-core/axe.min.js"; No filesystem access for this asset at request time, at all - so there was nothing left for Vercel's traced filesystem to get wrong. Redeployed. Real scan, real 200, real violations back.

What actually mattered

  • Every fix attempt before we added logging was a guess dressed up as a diagnosis. Two of them were plausible, well-reasoned, and completely wrong. The moment we had real production logs with step markers and stack traces, the third fix was obvious in about a minute.
  • A hypothesis a single clean test can falsify isn't safe to trust just because it's plausible. "It's a race condition" sounded right until one non-concurrent request disproved it outright.
  • If a file is truly static, don't read it from disk in the request path at all - especially in a serverless environment whose deployed filesystem is a build artifact, not a real disk. Inlining beats debugging a filesystem you don't control.

This scanner is what powers 3Stone Shield API - real axe-core WCAG scanning plus an AI-generated report, as a real HTTP call.