Building a Modern HTTP Server with Promises and Async/Await

Master asynchronous JavaScript through practical server development

Understanding Asynchronous Programming

Imagine you're at a busy restaurant. When you place your order, the waiter doesn't stand at your table waiting for the kitchen to prepare your food. Instead, they take your order (like making a Promise), continue serving other tables (executing other code), and return with your food when it's ready (resolving the Promise). This is exactly how asynchronous programming works in JavaScript!

In our web server, we'll be handling multiple requests simultaneously, just like a waiter managing multiple tables. Each request is like a new customer walking into the restaurant - we need to handle them efficiently without making others wait unnecessarily.

Setting Up Our Project

Let's create our server piece by piece. First, we'll need Node.js installed on our system. Think of Node.js as our restaurant building - it provides the space and basic utilities we need to run our server.


// server.js
const http = require('http');
const fs = require('fs').promises;
const path = require('path');

const PORT = 3000;
                

Creating Our First Server

Here's our basic server setup, similar to opening our restaurant for the first time:


const server = http.createServer(async (request, response) => {
    try {
        // Log incoming requests (like a hostess greeting customers)
        console.log(`Incoming ${request.method} request to: ${request.url}`);
        
        // Handle the request
        await handleRequest(request, response);
    } catch (error) {
        // Error handling (like dealing with customer complaints)
        console.error('Server Error:', error);
        response.writeHead(500);
        response.end('Internal Server Error');
    }
});

server.listen(PORT, () => {
    console.log(`Server is running at http://localhost:${PORT}`);
});
                

Request Handler - The Heart of Our Server

Think of the request handler as our master chef - it determines what needs to be prepared based on the customer's order (request URL):


async function handleRequest(request, response) {
    const { url, method } = request;
    
    // Serve different content based on URL (like different menu items)
    if (url === '/') {
        // Serve home page
        await serveFile('index.html', 'text/html', response);
    } else if (url === '/api/data') {
        // Handle API requests
        await handleAPI(request, response);
    } else {
        // Try to serve static files
        try {
            const filePath = path.join('public', url);
            await serveFile(filePath, getContentType(url), response);
        } catch {
            response.writeHead(404);
            response.end('Not Found');
        }
    }
}
                

File Server - Our Kitchen Storage

Just as a restaurant needs an organized storage system for ingredients, our server needs a way to manage and serve files:


async function serveFile(filePath, contentType, response) {
    try {
        const data = await fs.readFile(filePath);
        response.writeHead(200, { 'Content-Type': contentType });
        response.end(data);
    } catch (error) {
        throw new Error(`Error serving ${filePath}: ${error.message}`);
    }
}

function getContentType(url) {
    const extensionMap = {
        '.html': 'text/html',
        '.css': 'text/css',
        '.js': 'text/javascript',
        '.json': 'application/json',
        '.png': 'image/png',
        '.jpg': 'image/jpeg'
    };
    return extensionMap[path.extname(url)] || 'text/plain';
}
                

API Handler - Our Special Orders

Like handling special dietary requirements or custom orders, our API handler manages special data requests:


async function handleAPI(request, response) {
    const { method } = request;
    
    if (method === 'GET') {
        // Simulate database fetch
        await new Promise(resolve => setTimeout(resolve, 100));
        const data = { message: 'Hello from the API!' };
        response.writeHead(200, { 'Content-Type': 'application/json' });
        response.end(JSON.stringify(data));
    } else if (method === 'POST') {
        let body = '';
        
        // Collect data chunks (like gathering ingredients)
        request.on('data', chunk => {
            body += chunk.toString();
        });
        
        // Process the complete request
        await new Promise((resolve, reject) => {
            request.on('end', resolve);
            request.on('error', reject);
        });
        
        // Process the data (like preparing a meal)
        const data = JSON.parse(body);
        response.writeHead(200, { 'Content-Type': 'application/json' });
        response.end(JSON.stringify({ received: data }));
    }
}
                

Real-World Applications

This server architecture forms the foundation of many real-world applications:

Error Handling and Logging

Just as a restaurant needs systems to handle spills, complaints, and accidents, our server needs robust error handling:


// Add this to your server setup
process.on('uncaughtException', (error) => {
    console.error('Uncaught Exception:', error);
    // Log to file or monitoring service in production
});

process.on('unhandledRejection', (reason, promise) => {
    console.error('Unhandled Rejection at:', promise, 'reason:', reason);
    // Log to file or monitoring service in production
});
                

Understanding Fetch API

Think of the Fetch API as a courier service in our restaurant analogy. Just as a delivery service picks up food from restaurants and delivers it to customers, fetch retrieves data from servers and delivers it to our client applications.


// Basic GET request
fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('Error:', error));

// Same request using async/await
async function getData() {
    try {
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}
                

Advanced Fetch Usage

Like a restaurant offering different delivery options (express, scheduled, or special handling), fetch supports various request configurations:


// POST request with custom headers and body
async function postData(url, data) {
    const options = {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer your-token-here'
        },
        body: JSON.stringify(data)
    };

    try {
        const response = await fetch(url, options);
        
        // Check if the request was successful
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const result = await response.json();
        return result;
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
}

// Example usage with different request methods
async function apiExamples() {
    try {
        // PUT request
        const updateResponse = await fetch('/api/update', {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ id: 1, status: 'updated' })
        });

        // DELETE request
        const deleteResponse = await fetch('/api/delete/1', {
            method: 'DELETE'
        });

        // Request with timeout
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), 5000);
        
        const timeoutResponse = await fetch('/api/data', {
            signal: controller.signal
        });
        clearTimeout(timeoutId);
        
        // Handling file uploads
        const formData = new FormData();
        formData.append('file', fileInput.files[0]);
        
        const uploadResponse = await fetch('/api/upload', {
            method: 'POST',
            body: formData
        });
    } catch (error) {
        console.error('API Error:', error);
    }
}
                

Error Handling with Fetch

Just as a delivery service needs to handle various situations (wrong address, closed restaurant, traffic delays), we need robust error handling for our fetch requests:


async function robustFetch(url, options = {}) {
    // Default retry configuration
    const MAX_RETRIES = 3;
    const RETRY_DELAY = 1000; // ms
    
    let lastError = null;
    
    for (let i = 0; i < MAX_RETRIES; i++) {
        try {
            const response = await fetch(url, {
                ...options,
                // Add default headers
                headers: {
                    'Content-Type': 'application/json',
                    ...options.headers,
                }
            });

            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }

            const data = await response.json();
            return data;

        } catch (error) {
            lastError = error;
            
            // Don't retry on certain errors
            if (error.name === 'AbortError' || 
                (error.response && error.response.status === 404)) {
                throw error;
            }

            // Wait before retrying
            if (i < MAX_RETRIES - 1) {
                await new Promise(resolve => 
                    setTimeout(resolve, RETRY_DELAY * (i + 1))
                );
            }
        }
    }

    throw lastError;
}

// Usage example
async function fetchWithRetry() {
    try {
        const data = await robustFetch('/api/data', {
            method: 'POST',
            body: JSON.stringify({ key: 'value' })
        });
        console.log('Success:', data);
    } catch (error) {
        console.error('Final error:', error);
    }
}
                

Real-World Fetch Patterns

Common patterns you'll encounter in production applications:

Testing Our Server

Here's a simple test script to verify our server's functionality:


// test.js
async function testServer() {
    try {
        // Test GET request
        const response = await fetch('http://localhost:3000/api/data');
        const data = await response.json();
        console.log('GET response:', data);

        // Test POST request
        const postResponse = await fetch('http://localhost:3000/api/data', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ test: 'data' })
        });
        const postData = await postResponse.json();
        console.log('POST response:', postData);
    } catch (error) {
        console.error('Test failed:', error);
    }
}

testServer();
                

Further Topics to Explore

Conclusion

Building a web server with async/await and promises makes our code more readable and maintainable. Just as a well-organized restaurant can serve many customers efficiently, our server can handle multiple requests smoothly. Keep practicing and experimenting with different features to become a master of server-side JavaScript!