DEV Community

Vojtěch Klepáček
Vojtěch Klepáček

Posted on

Your API Works. But Is It Actually Reliable?

Building a REST API is easy. Building one that handles things going wrong? That's where it gets interesting.

When I first started working with APIs, I mostly cared about one thing: Does the endpoint return the correct data?

If I sent a GET request and received JSON, I considered it working.

But there's a lot more to a reliable API than returning 200 OK.

What happens when the client sends invalid data? What if a resource doesn't exist? What if something breaks on the server?

Let's look at a few simple things that can make a Node.js API much more reliable.

1. Use the Right HTTP Status Codes

HTTP status codes aren't just random numbers. They tell the client what happened.

Here are some common ones:

Status Meaning
200 OK The request succeeded
201 Created A resource was created
400 Bad Request The client sent an invalid request
401 Unauthorized Authentication is required or invalid
403 Forbidden The client isn't allowed to access the resource
404 Not Found The requested resource doesn't exist
500 Internal Server Error Something unexpected failed on the server
503 Service Unavailable The server temporarily can't handle the request

For example, if someone requests a user that doesn't exist, returning 500 isn't helpful.

Instead:

app.get("/api/users/:id", (req, res) => {
    const user = users.find(u => u.id === req.params.id);

    if (!user) {
        return res.status(404).json({
            error: "User not found"
        });
    }

    res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

Now the client knows exactly what happened.

2. Never Trust User Input

Just because your API expects valid data doesn't mean it will always receive it.

Imagine an endpoint that creates a new user:

app.post("/api/users", (req, res) => {
    const { username } = req.body;

    users.push({ username });

    res.status(201).json({ message: "User created" });
});
Enter fullscreen mode Exit fullscreen mode

What if someone sends an empty request?

Your API might create a user without a username.

Let's fix that:

app.post("/api/users", (req, res) => {
    const { username } = req.body ?? {};

    if (typeof username !== "string" || !username.trim()) {
        return res.status(400).json({
            error: "Username is required"
        });
    }

    const user = {
        id: crypto.randomUUID(),
        username: username.trim()
    };

    users.push(user);

    res.status(201).json(user);
});
Enter fullscreen mode Exit fullscreen mode

Now invalid requests are rejected before they affect your data.

Note: This example uses an in-memory array. Real applications should use persistent storage and appropriate database constraints.

3. Handle Errors Instead of Crashing

Let's say your API reads data from a file.

app.get("/api/config", (req, res) => {
    const config = JSON.parse(
        fs.readFileSync("config.json", "utf8")
    );

    res.json(config);
});
Enter fullscreen mode Exit fullscreen mode

What if config.json doesn't exist or contains invalid JSON?

An exception will be thrown.

Express has error-handling behavior, but you should still decide what response your API should return.

A better approach:

app.get("/api/config", async (req, res) => {
    try {
        const data = await fs.promises.readFile(
            "config.json",
            "utf8"
        );

        res.json(JSON.parse(data));
    } catch (error) {
        console.error("Failed to load configuration:", error);

        res.status(500).json({
            error: "Unable to load configuration"
        });
    }
});
Enter fullscreen mode Exit fullscreen mode

This also avoids exposing internal file paths or stack traces to the client.

4. Authentication Is Not Authorization

These two terms sound similar, but they mean different things.

Authentication: Who are you?

Authorization: Are you allowed to access this resource?

Imagine a user successfully logs into your application.

That doesn't automatically mean they should be able to access every user's private information.

For example:

app.get("/api/users/:id/profile", authenticate, (req, res) => {
    if (req.user.id !== req.params.id) {
        return res.status(403).json({
            error: "Access denied"
        });
    }

    res.json(getProfile(req.user.id));
});
Enter fullscreen mode Exit fullscreen mode

Here, authenticate is middleware that must verify the user's credentials and populate req.user. The example assumes that middleware has already been implemented securely.

The important part is that the API checks which resource the authenticated user is allowed to access.

5. Test the Bad Cases Too

It's easy to test whether an endpoint works when everything goes right.

But some of the most valuable tests are the ones that intentionally send invalid requests.

Node.js even has a built-in test runner.

const { test } = require("node:test");
const assert = require("node:assert/strict");

test("unknown user returns 404", async () => {
    const response = await fetch(
        "http://localhost:3000/api/users/unknown"
    );

    assert.equal(response.status, 404);

    const body = await response.json();
    assert.equal(body.error, "User not found");
});
Enter fullscreen mode Exit fullscreen mode

This test assumes your API server is already running.

You can run it using:

node --test
Enter fullscreen mode Exit fullscreen mode

A few things worth testing:

  • Missing or invalid input
  • Unknown resources
  • Missing authentication
  • Insufficient permissions
  • Unexpected server errors

Tests like these help catch regressions before users encounter them.

6. Don't Leak Sensitive Information in Logs

Logging is incredibly useful when debugging an API.

But logging everything can create security problems.

For example:

console.log(req.headers);
console.log(req.body);
Enter fullscreen mode Exit fullscreen mode

This might expose passwords, access tokens, or other sensitive information.

Instead, log only what you need:

console.log({
    method: req.method,
    path: req.path,
    timestamp: new Date().toISOString()
});
Enter fullscreen mode Exit fullscreen mode

If you need more detailed logging, make sure sensitive fields are properly redacted.

Final Thoughts

A working API isn't necessarily a reliable API.

Returning the right JSON is just the beginning.

A reliable API should:

  • Return meaningful HTTP status codes
  • Validate incoming data
  • Handle unexpected failures
  • Enforce authentication and authorization
  • Include tests for failure scenarios
  • Avoid exposing sensitive information

You don't need a complicated architecture to start improving API reliability.

Sometimes, a few well-placed checks and tests can make a huge difference.

What's one API mistake you've learned from?

I'd be interested to hear what other developers have encountered.

Top comments (0)