← all writing
5 min read

The Node and Python split I keep landing on

Why I'm reaching for Python alongside Node, and where the seam between them tends to be.

For the last six months my default backend hasn't been Node — it's been Node and Python. Not because Python is fashionable again, and not because Node ever disappointed me. It's because the work I keep getting handed has a shape that splits cleanly between the two, and I finally stopped fighting it.

The afternoon I gave up parsing PDFs in Node

This started at Remolution. We were a talent acquisition SaaS, 100+ enterprise customers, and a non-trivial slice of the product was: a recruiter uploads a candidate's CV, we extract the structured fields — name, contact, experience, education, skills — and surface them in the pipeline. Sounds simple. It is not.

The CV that broke my brain was a scanned PDF an enterprise customer uploaded sometime in 2024. Two-column layout, a sidebar with little skill bars, an embedded signature image, OCR-able but only barely. pdf-parse returned a wall of text where the right column had been interleaved with the left, line by line. pdfjs-dist extracted the words with coordinates, but reassembling them into reading order was a project I did not want to ship.

I spent the better part of a Tuesday afternoon trying to make Node do this. By 9pm I had a working extractor for that one CV and three new failing ones already in the support queue. I closed the laptop, and the next morning I rewrote the whole thing as a small Python service. pdfplumber gave me layout-aware extraction in roughly 40 lines. The 9pm hack had been around 400.

The seam isn't a failure of architecture — it's where each language does its best work.

Where the seam actually lives

The seam, for me, is always the same shape: structured data out of unstructured input. PDFs, CVs, scanned forms, free-text fields, screenshots, the body of an email a user copy-pasted into a textarea. The input has no schema; the output needs one. That transformation is where Python earns its keep.

Around the seam, Node still owns everything. The request lands on a Next.js route handler, auth runs, the file gets persisted to blob storage, a row gets inserted, an event gets emitted. None of that should change languages. The Node side of the seam is fast, well-typed, easy to reason about, and the App Router gives me streaming and Server Actions for free.

The Python side is the part where I'd previously been gluing together five libraries and a regex. Once I let it have its own runtime, the code got smaller and the bugs got rarer.

What Node keeps and what Python takes

The split I've landed on, more or less:

Node keeps the HTTP routing, auth, the Next.js render path, server actions, anything sub-200ms, anything that touches the user's session, anything that returns JSON to the browser. Node is great at the surface area of the app. It's been great at it for years.

Python takes anything where the input is a file I can't trust the shape of. PDF parsing. OCR. Resume scoring. Document classification. Anything where the answer "the team that wrote the library already solved your problem" is true — and in the document-extraction world, that team is almost always Python.

There's a third bucket — anything ML-adjacent — that I used to think Node could handle with ONNX or a third-party API. Sometimes it can. But the moment I need to chain a pre-processor, a model, and a post-processor with shared types, I'm back in Python and happier.

The interface I keep landing on

I went through three shapes before settling.

First was child_process.spawn. Cheap, no network hop, easy to demo. It failed the moment the Python process needed to live longer than one request — model warm-up alone was eating most of a second per call.

Second was a queue. Node enqueues a job, a Python worker picks it up, result comes back on a channel. Right for batch work, wrong for the case where the user is sitting in front of the UI waiting for the upload to finish.

What I landed on is the most boring option: a FastAPI sidecar, HTTP between them, Pydantic schemas as the contract. The Next.js route validates the upload, posts the file to the Python service, awaits the response, returns it to the client. The model stays warm. The schema is shared via a generated TypeScript type. The whole thing is two services on the same network — which, on Vercel with Fluid Compute supporting both Node and Python runtimes natively, is roughly zero ceremony.

What I'd tell past me

Stop trying to make Node do PDFs. Stop trying to make Python serve the request surface. Two runtimes is not a failure of taste — it's an honest match between the work and the tool. The cost is one extra deploy target. The benefit is code that doesn't make you want to quit at 9pm.

The thing I still get wrong

I keep underestimating the schema work between the two sides. Pydantic on the Python end is delightful; Zod on the Node end is fine. But when the shape changes — a new field, a renamed enum, a nested object — I have two places to update and there's no compiler that yells at me when I do one and not the other.

The fix I'm trying right now is generating both sides from a single source: a small JSON Schema document that Pydantic and Zod both consume. It's the kind of tooling problem I used to hand-wave away. Six months in, hand-waving costs me about an hour a week in confused integration bugs, so I'm finally fixing it.

Where this leaves me

The agentic work I'm doing now at PSA — ShopQuantum.AI's autonomous storefront, FangBot.AI's 59-employee virtual business — has the same seam, just bigger. The deterministic surface is Node. The probabilistic, document-y, "give me structure from this mess" surface is Python. Tool calls cross the seam constantly, and the discipline I picked up at Remolution is the same one I'm using now: decide which side of the seam each piece of work belongs on, and don't let the runtime choice be the thing you fight.

The seam is fine. It was always fine. I just spent too long pretending one language was going to do both jobs because one deploy target sounded simpler than two. The deploy target was the cheap part. The hour I spent at 9pm on a Tuesday in CV-extraction land was the expensive part. I'd rather pay it once, in container config, than every week, in regex.