Stuck with Node.js behind the scenes?
You've written JavaScript for the frontend, but when you move to backend with Node.js, you feel like you've lost control. The event loop spins in your head, NPM modules seem like a maze, and your first HTTP server doesn't respond the way you expect. Don't worry, it's normal. We at Meteora Web started exactly the same way. But with a bit of method — and some concrete examples — the fog clears quickly.
In this practical guide, we won't give you the usual academic theory. We start from code and real-world behavior. By the end, you'll have a working server and understand why Node.js handles thousands of requests without breaking a sweat.
The Node.js Event Loop: The Async Heart
Before writing a single line of server code, you need to understand how Node.js executes code. JavaScript is single-threaded, but Node.js never stops. The secret is the event loop: a cycle that handles asynchronous operations (file reads, HTTP requests, timers) without blocking the main thread.
Imagine a kitchen with one chef (the thread). Instead of waiting for water to boil, the chef starts other preparations, then checks back. The event loop works the same way: it executes code, puts callbacks in queues, and runs them when ready.
How the Event Loop Runs
The event loop has six phases (timers, I/O callbacks, idle, poll, check, close). You don't need to memorize all of them. The key is that asynchronous operations (setTimeout, fs.readFile, http.request) do not block. Here's an example that makes it clear:
console.log('1 - sync');
setTimeout(() => {
console.log('2 - timer 0ms');
}, 0);
Promise.resolve().then(() => {
console.log('3 - microtask');
});
console.log('4 - sync');
Run this script. The output will be:
1 - sync
4 - sync
3 - microtask
2 - timer 0ms
Why? Microtasks (Promise) have priority over timer callbacks. The event loop first empties the sync stack, then runs all microtasks, then moves to the timer phase. If you don't understand this, debugging your servers will be a nightmare.
Common Mistake: Thinking Async = Immediate
Many developers expect a setTimeout with 0ms to run immediately. Instead, it goes to a queue. The same applies to network requests. Node.js doesn't wait: it registers the callback and moves on. When the response arrives, the event loop picks it up in the next cycle.
What to do now: Create a file event-loop-demo.js with the code above and run it with node event-loop-demo.js. Play with Promise and setTimeout to see the order. It's the first step to trusting asynchrony.
NPM: Managing Dependencies Without Going Crazy
Node Package Manager (NPM) is the most widely used package manager in the world. But we often use it passively: npm install and go. Without understanding what happens under the hood. We've seen projects with node_modules of 500 MB because someone installed everything without checking. Here's how to use NPM consciously.
The Core: package.json
Every Node.js project has a package.json. It contains name, version, dependencies, scripts. Create one with npm init -y. Then install a package, e.g., express:
npm init -y
npm install express
Now look at package.json: under dependencies you'll find "express": "^4.18.2". The caret (^) allows minor updates. node_modules contains express code and its dependencies. Never modify node_modules manually.
Dependencies vs DevDependencies
A common mistake: putting everything in dependencies. Use --save-dev for development tools (tests, linters, compilers). Example:
npm install --save-dev jest
In production, npm install --production installs only runtime dependencies. Reduce attack surface and disk space.
Custom Scripts
In package.json, add scripts to start the server, test, run migrations. Example:
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "jest"
}
Then run npm run dev. Node.js 18+ supports --watch for auto-restart on changes.
What to do now: In a new project, initialize NPM, install Express as a dependency, add a "start" script, and verify it works with npm start (by default runs node server.js).
Your First HTTP Server
Now let's put it all together. We'll create an HTTP server that responds to GET and POST requests. We'll use Node.js' native http module, no framework. You'll understand exactly what happens, then you can move to Express.
Basic Server with http Module
// server.js
const http = require('http');
const server = http.createServer((req, res) => {
console.log(`Request: ${req.method} ${req.url}`);
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from Meteora Web!');
} else if (req.method === 'GET' && req.url === '/api') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'It works!' }));
} else {
res.writeHead(404);
res.end('Not found');
}
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});
Save as server.js, run node server.js, and open http://localhost:3000 in your browser. It works.
Handling POST Requests and Body
To receive data from a form or API call, you need to read the body stream. Here's how:
if (req.method === 'POST' && req.url === '/submit') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
console.log('Body received:', body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: body }));
});
}
Note: without a framework you handle parsing yourself. For JSON use JSON.parse(body). For URL-encoded, use querystring or new URLSearchParams.
Common Mistakes to Avoid
- Not calling res.end(): the request stays open and the browser waits. Always close the response.
- Reading body twice: the stream is consumed; once read you can't read it again.
- Not setting Content-Type: browser defaults to text/html. For JSON you must specify it.
- Port already in use: if you get
EADDRINUSE, change port or kill the process.
What to do now: Extend the server with a POST route that receives a name and responds with a personalized greeting. Test it with curl or Postman.
From Native Server to Express
Now that you understand the underlying mechanism, you can use Express without fear. Knowing what app.get() does under the hood saves you from mysterious bugs. Express is not magic: it's a library that wraps http.createServer and abstracts routing, body parsing, middleware.
// With Express
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello from Express!');
});
app.listen(3000);
Clean, right? But if you don't understand the event loop and the http module, when a misordered middleware or unconsumed stream error hits, you lose hours.
In Summary — What to Do Now
- Run the event loop demo: create a file with setTimeout, Promise, console.log. Observe the order.
- Create a native HTTP server: write the code above, test with curl. Then add a POST route and test it.
- Initialize NPM in a project:
npm init -y, install Express, create astartscript. - Read a request body: in the native server, handle a POST with JSON data. Parse the body and return a response.
- Dive deeper into routing: if you want client-side routing, check out our Next.js App Router guide.
Node.js is no longer a mystery. You now have the tools to understand how the engine runs. It's your turn to write the next server.
Sponsored Protocol