HomeBlog → Speech to text in Node.js: working code and the usual pitfalls

Speech to text in Node.js: working code and the usual pitfalls

Published 2026-08-05 · 6 min read

In Node.js the transcription call itself is trivial. What trips people up is everything around it: file handles versus buffers, uploads arriving from a browser, and a default timeout that quietly kills long recordings.

Install and first request

The official SDK works unchanged — only the base URL and key differ:

npm install openai
import fs from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://voicesscribe.com/v1",
  apiKey: process.env.VS_KEY,
});

const r = await client.audio.transcriptions.create({
  model: "whisper-1",
  file: fs.createReadStream("audio.mp3"),
});

console.log(r.text);

Note createReadStream rather than readFileSync: the file is streamed to the server instead of being loaded into memory first. On a server handling several requests at once, that difference is the whole memory profile.

Audio that arrives from a browser

The common case is not a file on disk — it is an upload. With Express and multer the buffer has to be wrapped so the SDK sees a file with a name:

import express from "express";
import multer from "multer";
import { toFile } from "openai/uploads";

const upload = multer({ limits: { fileSize: 25 * 1024 * 1024 } });
const app = express();

app.post("/transcribe", upload.single("audio"), async (req, res) => {
  try {
    const file = await toFile(req.file.buffer, req.file.originalname);
    const r = await client.audio.transcriptions.create({
      model: "whisper-1", file,
    });
    res.json({ text: r.text });
  } catch (e) {
    res.status(502).json({ error: String(e) });
  }
});

The fileSize limit on multer matters: without it a browser can push a gigabyte into your process memory before anything rejects it. The API limit is 25 MB, so matching it upstream fails fast and cheaply.

Recording in the browser and sending it

The browser side produces webm from MediaRecorder, which is accepted as is — no conversion needed:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const rec = new MediaRecorder(stream);
const chunks = [];

rec.ondataavailable = (e) => chunks.push(e.data);
rec.onstop = async () => {
  const blob = new Blob(chunks, { type: "audio/webm" });
  const form = new FormData();
  form.append("audio", blob, "voice.webm");
  const res = await fetch("/transcribe", { method: "POST", body: form });
  console.log((await res.json()).text);
};

rec.start();
setTimeout(() => rec.stop(), 5000);

Never put the API key in browser code. The upload goes to your own endpoint, and your server holds the key — that is the only safe arrangement.

Timeouts and retries

The default request timeout suits short clips and silently kills long ones. Set it from the length of audio you actually handle, and let the SDK retry the transient failures:

const client = new OpenAI({
  baseURL: "https://voicesscribe.com/v1",
  apiKey: process.env.VS_KEY,
  timeout: 5 * 60 * 1000,   // five minutes: enough for an hour-long recording
  maxRetries: 2,            // 5xx and connection errors, handled by the SDK
});

Retries are worth having for 5xx and connection errors. A 400 on a corrupted file will fail identically every time, so retrying it just wastes the wall-clock.

Formats and timestamps

Subtitles and segment timings come from the same call, with one parameter:

// ready-made subtitle file as a string
const srt = await client.audio.transcriptions.create({
  model: "whisper-1", file, response_format: "srt",
});

// segments with timings
const detailed = await client.audio.transcriptions.create({
  model: "whisper-1", file, response_format: "verbose_json",
});

for (const s of detailed.segments) {
  console.log(s.start.toFixed(1), s.text.trim());
}

Errors worth handling explicitly

CodeMeaningWhat to do
401Wrong or blocked keyCheck the env variable actually reached the process
413File over 25 MBRe-encode to mono 16 kHz, then split if needed
429Rate limit or minutes exhaustedBack off exponentially; do not retry immediately
502Recognition node unavailableRetry after a pause

An empty text in a successful response is not an error — it means there was no speech in the recording. Handle it as a normal outcome and do not retry.

Try it on your own recordings. Sign-up takes a minute, and the free minutes are enough to judge the quality.

Get a free API key

Frequently asked questions

Do I need a special Node.js client?

No, the official openai package works unchanged. The API is protocol-compatible, so only baseURL and apiKey differ from the OpenAI defaults.

How do I pass an uploaded file from Express?

Wrap the buffer with toFile from openai/uploads so the SDK sees a proper file with a name, then pass it as the file field.

Can I call the API directly from the browser?

No — that would expose your API key to anyone who opens the page. Upload to your own endpoint and keep the key on the server.

Is webm from MediaRecorder accepted?

Yes, webm is accepted as is, along with ogg, opus, mp3, wav and m4a. No conversion step is needed.

Why do long recordings fail with a timeout?

The client default is tuned for short requests. Raise the timeout option to match the longest audio you handle — five minutes covers an hour-long recording comfortably.

Related reading