Don’t Rewrite Your Node.js App in Rust. Do This Instead.

summarized

TLDR

Node's new FFI (foreign function interface), enabled by default in the latest release, lets you call native libraries directly from JavaScript without C++ add-ons or build steps, making it practical to replace a single hot function instead of rewriting the whole backend. The main catch is that FFI calls are synchronous and block the event loop, so you must offload them to worker threads to avoid freezing requests under load. This is a targeted fix for one slow function, not a replacement for mature add-ons like Sharp.

Key points

Node FFI is now enabled by default in the newest release, after arriving behind a flag in 26.1.

FFI lets JavaScript call native library functions without compiling a C++ add-on or build step.

Sharp resizes images about 26 times faster than pure JavaScript, using the libvips C library.

FFI calls are synchronous and block the event loop, so heavy work must run in worker threads.

The Node docs warn that FFI is unsafe; wrong types or pointers can crash the whole process.

Tools mentioned

Techniques

  • Foreign function interface (FFI)
  • Worker threads
  • Flame graph profiling
  • Buffer transfer to worker
Transcript (captions)

0:00 Here's a node endpoint resizing an uploaded photo. Watch the timer climb. One image takes long enough to notice and every other request just waits in line behind it. So, do you really need

0:10 to rewrite your whole backend to make one feature fast? No. You need to replace one function. About 4 days ago, Node shipped the thing that makes that easy. And in the newest release, it's on

0:20 by default. It's called node ffi, a foreign function interface. That just means your JavaScript can call code inside a native library directly. No. C++ add-on to compile. No node JIP, no

0:32 build step that breaks every time you upgrade Node. The plan is simple. Find where the time really goes. Swap only the slow part for native code. Then handle the catch that comes with it.

0:42 First, the stakes because this only matters under load. One image is fine, but 10 users uploading at once and the endpoint starts timing out. The average still looks fine while the slow tail

0:53 that users actually feel goes through the roof. They don't see a slow function. They see a spinner that never resolves and a dashboard that makes it look like the whole server is dying. It

1:02 isn't. One function is. So before you change a line, measure. Node ships a profiler. You run it with the CPU prof flag. It records a few real requests and you open the result as a flame graph.

1:14 The widest bar is where the time went and a single function sits right at the top. The resize, it eats almost all the time and that's the whole move. You don't optimize the app. You find the one

1:24 function that's actually expensive. But before reaching for native code, rule out the cheap fixes. Maybe the endpoint resizes the same image on every request and caching the result makes it

1:34 disappear. Maybe it's a synchronous file read or a loop inside a loop. Those cost nothing to fix. Native code is for when the work itself is heavy and the algorithm is already sane. So let's

1:45 trace one request and see why resizing is so heavy. A 4 megapixel photo comes in and you want a thumbnail. First, it's decoded. So the compressed file becomes raw pixels in memory. more than four

1:56 million of them. Then it's resampled. To shrink it, the code averages neighboring pixels across the whole grid, an operation called langos resampling. Then it's encoded back into a smaller file.

2:07 That middle step is the cost. A tight loop running millions of times. And in JavaScript, it runs slow. Now, the receipt sharp is a standard native image library for Node. And on its own

2:17 benchmark, it resizes that kind of photo about 26 times faster than the pure JavaScript one. Same operation, same image. The whole gap is only about where the code runs because sharp

2:29 doesn't do the pixels in JavaScript. It wraps a C library called lib vips and lets that run the loop in tight machine instructions while JavaScript goes through the runtime for every step. So

2:38 here's the diagnosis. One hot function, slow because of the language it runs in. Now the fix, the idea is small on purpose. Keep the entire app as it is and replace just that one function with

2:50 a call into a native library. A native library ships as a shared file. A dot so on Linux, a dyb on Mac, a DLL on Windows. Inside are compiled functions already fast. The problem was reaching

3:03 them from node. Until now, that meant writing a C++ wrapper and compiling it against your exact node version. Upgrade node and the binding can stop loading. A lot of us have lost an afternoon to

3:14 exactly that. Node FFI removes the whole layer. You point it at the library file and describe the function you want listing the argument types and the return type. That description is the

3:24 entire binding with no compiler and no build config. You describe each argument by type, a 32-bit integer, a pointer, a length. For a resize, you hand it a pointer to the image bytes plus the

3:36 width and height you want back. The image crosses as a pointer, not a copy. Node gives the native side the same memory it already holds, which is why one big call is cheap. Millions of

3:46 pixels move for the price of a single crossing. It hands back a pointer to the new pixels, and this part you can't skip. You free that memory when you're done, or your server slowly leaks until

3:56 it falls over. In practice, you open the library with a disposable handle, so it closes itself. Then call the function like normal JavaScript and get the buffer back. So the swap is tiny. The

4:06 old route called the JavaScript resizer. The new one calls the native code through FFI. and your routes, middleware, and database code stay untouched. You replaced a function, not

4:16 a framework. And the payoff is that same speed up now inside your own endpoint. You could even point FFI straight at lib VIPS, the same engine Sharp uses. This didn't appear overnight, by the way.

4:27 Node FFI arrived in the spring behind a flag in the 26.1 release, and the newest release just switched it on by default. It's still marked experimental, so treat it that way. The rewrite that was going

4:39 to eat your next quarter turns out to be about 10 lines, which feels like the end. It isn't because there's a catch and it's where people get burned. Node runs your JavaScript on one thread, the

4:49 event loop. It serves thousands of requests by staying in constant motion, handling whatever is ready next. But a native FFI call is synchronous, so while it runs, it owns that thread completely.

5:00 Picture two requests arriving together. The first starts the native resize, and for that whole stretch, the event loop is frozen. The second request just sits there until the first one's pixels are

5:10 done. You made one function faster and handed yourself a new problem. Under load, the fast function still blocks every request behind it. And that's the trap. Here's why it hurts in plain

5:21 numbers. Say one resize takes a fixed slice of time. 10 requests on the main thread don't overlap. They line up and run one after another. So the 10th user waits for all nine ahead. This is Node's

5:33 oldest rule and it's written in their own docs. Don't block the event loop. The fix is to move the heavy work off the main thread, and that's what worker threads are for. A worker is a separate

5:43 thread with its own JavaScript. You hand it the image, it runs the native resize over there, and it posts the result back. You can even transfer the image buffer to the worker instead of copying

5:52 it so the handoff stays cheap. One tip, don't spawn a worker per request. Keep a small pool and reuse them. Move those 10 requests to a pool of four workers and four run at once. So the same 10 finish

6:05 in a fraction of the wall clock time while the main thread keeps serving. That parallelism only goes as high as the CPU cores you actually have and it bites hardest on a small box. Your

6:14 laptop hides it behind eight cores. A cheap two core server does not. The newest node release added web workers 2, the same interface browsers use, so it's a pattern you probably already know from

6:25 the front end. So now it's fast and it holds up under traffic. Two catches down, one more decides whether you should use FFI at all. Crossing from JavaScript into native code isn't free

6:36 because every call pays a small tax to translate the values. On one call, that tax is tiny. A public benchmark clocked at near 37 nanconds against a compiled add-on at about 35, but you pay it on

6:48 every call. Cross the boundary once with a whole image and it vanishes into the work. Cross it a million times in a tight loop and that tax becomes the bill. So FFI has a sweet spot. One call

7:00 that carries a lot of work, a whole buffer, a whole image, a whole file. For a hot inner loop hitting a tiny native function over and over, a compiled add-on still wins. That's what Sharp is

7:11 under the hood. So if you already have an add-on, FFI isn't an upgrade over it. And you don't have to guess because the same release shipped Node Bench, a built-in way to time the before and

7:21 after yourself. So here's the practical rule for images. If a mature native library already exists like sharp, just use it. When there's no add-on, node FFI is the tool, a vendor's.so, an operating

7:34 system library, or a small C routine you wrote yourself, now callable with no build tool chain. One thing to respect, loading a native library runs its code inside your process. So treat that dot

7:46 so like any dependency you ship, know where it came from, and use the permission flag to lock FFI down in production. and a warning straight from nodes docs. This API is unsafe. Describe

7:57 a function's types wrong or hand it a bad pointer and you don't get a clean error, you crash the whole process. So you wrap it one careful module around the native call, tested hard, and the

8:07 rest of your app stays away from the raw boundary. To be fair, sometimes the rewrite really is the right call. If native work is spread across the whole app, or the team's moving to Go or Rust

8:17 for other reasons, then swapping one function won't save you. But that's rarer than the rewrite proposal makes it sound. So here's where I land. Most slow node backends don't need a rewrite. They

8:27 need the single hot function burning the CPU replaced and nothing else. Measure it first on the machine you actually deploy to because the gap is usually wider in production than on your laptop.

8:38 Use a native add-on if one exists. Reach for node FFI when one doesn't. And either way, run it in a worker so it stays off the loop. It's the difference between a weekend of work and a quarter

8:48 of it. The endpoint we started with, the one freezing every request behind a single photo, is fast now and out of the way. Same app, one function. If you want more of these, where the scary rewrite

8:59 turns into the small fix that actually works, subscribe. See you in the next one.

Frontier News · by Hyperjump Technology