Built end to end — architecture to deployment
CallInsight
An AI call-analytics service where agents upload call recordings and get back a transcript, a sentiment timeline, a weighted performance scorecard and a downloadable coaching PDF.
01The problem
Handling a slow, blocking multipart upload to a third-party transcription engine without stalling the single-threaded Node event loop or holding the HTTP request open for the seconds — sometimes minutes — it takes to transcribe and analyse a call.
02The constraint
Transcription is long-running and asynchronous — the engine returns a job id and calls back later — so the upload has to be non-blocking, the result reconciled out-of-band by webhook, the database writes transactional, and live status pushed to several users (the uploader and team admins) across devices.
03What I built
Upload ingest uses multer with a 100 MB cap and an audio-only filter (mp3 / wav / ogg / flac / aac); ffprobe reads the duration and rejects clips under 30 seconds before anything heavy happens.
The raw file goes to S3 and a call row is created with status 'processing' behind a minutes-usage billing check, all inside a Sequelize transaction.
The crux: the blocking multipart stream to the external transcription API runs on a Piscina worker-thread pool, off the main event loop. The worker streams the file, passes a webhook URL and diarization hints, retries twice with backoff on transient failures, and returns a job id — the API thread never blocks.
Results reconcile out-of-band: the transcription API POSTs back text plus analysis (sentiment timeline, performance scorecard, coaching report); the backend derives aggregate metrics, updates the call, and fans live status out over Socket.io and FCM push, with a status-polling fallback.
The coaching PDF is generated client-side in the React app (jsPDF for structured analysis, react-pdf for markdown) as a browser download; Postmark handles only transactional email such as call sharing.
04The decision
Options considered
Transcription and sentiment are the CPU- and latency-heavy step. Node runs on one thread, so where that work happens decides whether the API stays responsive.
- ARun ASR and sentiment in-process on worker threads — full control, but you own and scale the ML.
- BStand up a separate transcription microservice — clean isolation, but another service to run.
- CCall a managed external transcription API, and use worker threads only to keep the blocking upload off the event loop.
Chose The managed external API, with Piscina only for the upload offload.
The trade-off I accepted
Running ASR in-process would mean no third-party dependency, but it also means owning models, GPUs and their scaling — a lot for the volume. I send the audio to a managed transcription API instead, and use a Piscina worker pool only to move the blocking multipart upload off the event loop so the API keeps serving requests while a call is in flight; results come back by webhook, with polling as a fallback. The cost is a hard third-party dependency, per-minute vendor billing, and webhook-delivery complexity — I accepted it because there's no ML infrastructure to run or scale, and the worker offload plus async reconciliation keeps the single-threaded API responsive under concurrent uploads.
05The result
- Uploads capped at 100 MB and a 30-second minimum, streamed to the transcription API on a Piscina pool sized to the host (max of 2 and three-quarters of the cores) so the event loop stays free.
- Results reconcile out-of-band by webhook — with a polling fallback and a 2× retry with backoff on transient upstream failures — and fan out live over Socket.io and push.
06Stack
- Node.js
- Express
- Piscina
- Sequelize
- MySQL
- AWS S3
- Socket.io
- Stripe
- Postmark
- React
- jsPDF