Home → Blog → Speech to text in Node.js: working code and the usual pitfalls
Speech to text in Node.js: working code and the usual pitfalls
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
| Code | Meaning | What to do |
|---|---|---|
| 401 | Wrong or blocked key | Check the env variable actually reached the process |
| 413 | File over 25 MB | Re-encode to mono 16 kHz, then split if needed |
| 429 | Rate limit or minutes exhausted | Back off exponentially; do not retry immediately |
| 502 | Recognition node unavailable | Retry 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 keyFrequently 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
- Speech to text in Python: from one file to a working script — A working Python speech-to-text setup in ten minutes: install, the first request, response formats, error handling, and the mistakes that cost the most time.
- Voice message transcription: Telegram, WhatsApp and other messengers — How to turn voice messages into text automatically: working with ogg/opus, short clips, silent recordings and ready code for chat bots that answer in text.
- How to build a batch transcription pipeline for an audio archive — Transcribing thousands of recordings without losing any: a work queue, concurrency, retries, resuming after a crash and keeping the bill under control.