Building an App: Node.js or Go for Backend?

7 min read

My Dilemma

When building my own app, I struggled with the backend technology choice.

My app needs multi-device content synchronization for users. It's a novel-writing app—who's going to have two devices open simultaneously switching back and forth? I figured periodic push-pull would suffice without going too extreme.

After testing some usage scenarios, I found significant problems.

With push-pull, you either go with Last Write Wins or expose conflicting content and let users resolve it themselves.

Either way seems troublesome and not a great user experience. I needed a seamless synchronization method.

So I decided to find a CRDT implementation. CRDT algorithms can automatically handle conflicts like magic. For specifics, see this paper.

The most commonly used CRDT library is Yjs. Yjs has very fast diffs, but to enable diffing, you need to store all blobs on the server. For a novel app, blobs won't be particularly large, but if users save frequently, the process of loading blobs from disk to memory is still quite inefficient.

My other core requirement is reducing server pressure to save money.

Yjs's most common synchronization mechanism, the provider, is websocket-based. My novel app doesn't need real-time collaboration, just good synchronization. So I decided to drop websocket and return to periodic push-pull. But storing blobs server-side remained an issue.

So I decided to only store update logs on the server. Each time a user pushes, just insert directly. For pulls, based on the local log index, send logs after the server's index, and the client applies them.

However, for a new device doing a full pull, the client might face significant pressure. But periodic snapshots with compaction can solve this. In this design, the server becomes a simple message queue-like entity—no websocket connections to maintain, not serving as the source of truth for user data, very lightweight. This is exactly the local-first experience I wanted. Writers lack a sense of security, which is also why I switched my app from web to Electron.

In my previous crude web implementation, I used Node.js. Now with a major architecture change, the backend needed to be rebuilt. So what language and framework should I choose? I need it to be as cost-effective as possible while supporting as many users as possible.

Why Consider Node.js

My app's previous MVP used a Node.js backend. No particular reason other than it was sufficient for my needs.

Let's quickly review Node.js's event loop model. Node.js's event loop follows the Reactor pattern. What is the Reactor pattern? You can think of it as one thread quickly accepting tasks, then "dispatching them out" so blocking operations are invisible to clients. This way, concurrency can easily scale very high. For Node.js, high-cost operations like network requests are handled by the OS kernel in the background; for low-level interfaces like disk I/O, libuv automatically allocates threads to handle them.

For example, when JS fires off a bunch of network requests, Node.js uses libuv to register sockets to the system's epoll or similar multiplexing monitoring list. The OS kernel then monitors the sockets, and when data arrives, it puts the registered callback into the macrotask queue for the V8 engine to execute in the next round. While waiting for network socket messages, the main thread can do other things. Besides the macrotask queue in libuv's event loop, Node.js's V8 engine also has its own two microtask queues (including process.nextTick and promises), which run between macrotask queues.

Note that if microtasks contain intensive computational tasks, they will block the main thread. For macrotasks, while I/O, encryption, compression, and other libuv built-in low-level async modules can be dispatched to the thread pool, pure JS computation cannot—unless you manually use worker threads, it will still block Node's main thread.

As long as you don't give the main thread heavy work, for most apps in early to mid development, Node.js is sufficient. My app is like this, which is why I initially used Node.js. With the new server architecture, assuming 1 million users with 250,000 writing simultaneously during peak hours, establishing 250,000 keep-alive long connections, and with periodic push-pull, each long connection can be considered fairly idle. So how much memory does one connection occupy? Kernel socket plus buffer at 10KB, plus V8 and libuv user-space object overhead, seems like at most 20KB. Even with poorly written code and closures preventing some objects from being garbage collected, 250,000 connections would total only 8GB, completely acceptable.

Of course, with many connections, if requests are frequent, Postgres would fail first. This can be solved by batching logs or doing compaction on the client side. After all, this is a local-first app. The source of truth is always local.

Why Consider Go

Compared to Node.js, Go seems to have some advantages.

First, for idle long connection overhead, Go is theoretically smaller than Node.js, but in real scenarios, as long as Node.js closures don't capture too much, the user-space overhead difference between the two isn't huge.

However, Go's concurrency differs from Node.js—it uses the GMP model: goroutine - machine thread - processor. Essentially, it uses some OS threads to manage all goroutines. Each processor has a queue, plus a global queue. After creating a goroutine, it's placed by default in the local queue of the processor to which the current thread belongs; if full, it goes to the global queue.

Once a thread is bound to a processor, it picks goroutines from its local queue to run; if the local queue is empty, it takes half from the global queue; if still empty, Go will take half from other processors' local queues. Only if there's still nothing will it idle. Also, if a thread is about to block due to a system call or similar, it releases its current processor, letting other threads utilize the idle resource.

With these mechanisms, Go should be able to utilize server cores more evenly than Node.js. This brings many latency benefits.

Especially in extreme cases. For instance, if many requests pile up simultaneously, even if computational overhead isn't large and won't block the main thread, Node.js's tail requests will see latency spikes; whereas Go can evenly utilize all cores, keeping latency overall under control. Node.js can manually use cluster for management, but the extra V8 overhead is hard to ignore.

Finally, for garbage collection, Go also has advantages over Node.js. Node.js uses copy-clear for short-lived objects, which is fast, but for scenarios with many long connections, these connections are promoted to the old generation. Node.js uses mark-sweep and mark-compact to handle old generation objects. All objects are on V8's heap, and after clearing garbage, to avoid memory fragmentation or make room for promoted new generation objects, V8 must move their positions. To move positions, V8 must stop the world, and an overly bloated heap will cause stop-the-world to hang the main thread. So for my app's use case, Node.js's GC might have issues. If there are too many connections, a long STW could even affect heartbeat keepalives, leading to cascading reconnection failures. Though this can be mitigated at the application layer.

Go's garbage collection is much more robust in such scenarios. Go's GC doesn't compact memory; it partitions blocks from the start, avoiding long blocking from STW heap scanning. Only the beginning and end of GC marking have brief STW pauses. Additionally, Go's GC is done concurrently, marking objects black, white, and gray, deleting pure white nodes. Though there's CPU overhead, latency is guaranteed. Finally, Go's compiler does escape analysis—if a variable is only allocated within a function, it won't go on the heap but on the stack, making GC even easier.

In summary, for my app, Go provides almost universally superior performance—both in memory usage and user experience.

Final Choice

Looks like Go is much stronger than Node.js. My choice seems obvious.

But I chose Node.js.

First, my "million users" assumption doesn't hold. Still in early development—Node.js is enough. When user volume actually scales up, I can change it then. After all, my server is just like a dumb MQ.

Also, JS/TS is an excellent glue language. Besides synchronization, my app has many time-consuming engineering tasks. Node.js for both frontend and backend saves time and effort; using Go would be a little inconvenient.

Finally, because I'm lazy.

← Back to all blogs