NGINX njs now supports QuickJS: Goodbye LUA, hello modern JavaScript

Finally: Modern JavaScript in NGINX (and we can forget about LUA)

When I read the NGINX announcement about QuickJS support in njs, I couldn’t help but smile. Finally I can stop struggling with LUA.

As someone who has configured more NGINX servers than I can remember (from my time at Arrakis to now at Carto), I’ve always been annoyed by the limitation of having to use LUA for complex logic in NGINX. It’s not that LUA is bad, but… why learn another language when I already master JavaScript?

The NGINX team has just solved this problem by introducing QuickJS support in njs version 0.9.1, giving us full ES2023 compatibility. And honestly, this is a change we’ve needed for a long time.

The Problem We’ve All Lived Through

If you’ve worked seriously with NGINX, you know the frustration. You have two options when you need advanced logic:

  1. njs with limited ES5: Functional but archaic
  2. OpenResty with LUA: Powerful but… seriously, do I have to learn LUA?

As the NGINX team says in their article, it was like “trying to build a modern web application but being limited to tools from a decade ago”. Exactly that.

For years we’ve had to juggle between simplicity and functionality. I’ve personally avoided complex logic in NGINX precisely because of this. It was easier to delegate to the backend application than struggle with LUA.

QuickJS: The Engine We Needed

The decision to integrate QuickJS is brilliant. Developed by Fabrice Bellard (yes, the same guy from QEMU and FFmpeg), QuickJS offers:

  • Complete ES2023: Modules, async/await, destructuring, BigInt… everything you’d expect
  • Minimal footprint: Only 367 KiB for a simple program
  • No external dependencies: Just a few C files
  • Drop-in compatibility: Your existing njs scripts work without changes

The best part is that it maintains njs’s original philosophy: lightweight and focused, but now with modern JavaScript tools.

Configuration: Simpler Than Expected

Switching to the QuickJS engine is as simple as adding a directive:

# nginx.conf
load_module modules/ngx_http_js_module.so;

events {}

http {
    js_import main from js/main.js;

    server {
        listen 8000;

        # Traditional njs engine (ES5)
        location /njs {
            js_content main.handler;
        }

        # QuickJS engine (ES2023)
        location /qjs {
            js_engine qjs;
            js_content main.handler;
        }
    }
}

And in your JavaScript script:

// js/main.js
function handler(r) {
    r.return(200, `Hello from ${njs.engine}`);
}

export default { handler };

It’s that simple! You can have both engines running simultaneously during migration.

A Real Example: Header Analysis with ES2023

This is where things get interesting. With QuickJS, you can use modern JavaScript features that make code much cleaner:

// js/analytics.js
class RequestAnalytics {
    // Generator function to process headers
    *getHeaderMetrics(headers) {
        for (const [key, value] of Object.entries(headers)) {
            yield {
                header: key.toLowerCase(),
                size: key.length + value.length,
                type: key.startsWith('x-') ? 'custom' : 'standard'
            };
        }
    }

    processRequest(r) {
        // Destructuring with default values
        const {
            method = 'GET',
            uri = '/',
            httpVersion = '1.0'
        } = r;

        // Use the generator
        const headerStats = [];
        for (const metric of this.getHeaderMetrics(r.headersIn)) {
            headerStats.push(metric);
        }

        const timestamp = BigInt(Date.now()); // Native BigInt
        const headerCount = headerStats.length;
        const customHeaders = headerStats.filter(({ type }) => type === 'custom').length;

        r.return(200, JSON.stringify({
            message: `Request processed in ${timestamp}`,
            stats: { headerCount, customHeaders },
            serverInfo: `${method} ${uri} HTTP/${httpVersion}`
        }, null, 2));
    }
}

const analytics = new RequestAnalytics();
export default { processRequest: (r) => analytics.processRequest(r) };

This was previously impossible with traditional njs. Classes, generators, destructuring, BigInt… everything works perfectly.

Performance: What You Need to Know

As always in infrastructure, there are trade-offs. According to NGINX tests:

ConfigurationRequests/secLatencyvs traditional njs
njs (ES5)93,91542.64μsBaseline
QuickJS (context reuse: 128)94,51843.07μs+0.6%
QuickJS (no context reuse)5,363742.18μs-94.3%

The key is js_context_reuse (enabled by default). With context reuse, QuickJS performs the same as traditional njs. Without it, it’s unusable for high load.

The default configuration is perfect for most cases:

http {
    js_engine qjs;
    js_context_reuse 128;  # 128 reusable contexts (default)

    # Your configuration...
}

My Personal Migration Plan

As someone managing several infrastructures, my strategy will be:

Phase 1: Development Experiments

  • Configure QuickJS in dev environments
  • Migrate simple scripts to test compatibility
  • Measure performance under real loads

Phase 2: Specific Use Cases

  • Intelligent rate limiting: Using ES6 Maps and Sets
  • Dynamic request routing: With async/await for API calls
  • Structured logging: With template literals and modern JSON

Phase 3: New Functionality

  • Take advantage of features I previously avoided due to njs limitations
  • Eliminate LUA dependencies (finally!)
  • Consolidate all NGINX logic in JavaScript

Use Cases Where This Will Shine

With modern JavaScript available, new possibilities open:

Advanced API Gateway

// Dynamic routing with async/await
async function routeRequest(r) {
    const config = await loadRoutingConfig();
    const route = config.routes.find(r =>
        r.pattern.test(r.uri) && r.method === r.method
    );

    if (route?.requiresAuth) {
        const isValid = await validateToken(r.headersIn.authorization);
        if (!isValid) {
            r.return(401, 'Unauthorized');
            return;
        }
    }

    r.internalRedirect(route.target);
}

Intelligent Cache with Headers

// Using Maps for cache strategies
const cacheStrategies = new Map([
    ['api', { ttl: 300, vary: ['authorization'] }],
    ['assets', { ttl: 86400, vary: [] }],
    ['dynamic', { ttl: 60, vary: ['user-agent', 'accept-language'] }]
]);

function setCacheHeaders(r) {
    const path = r.uri;
    const strategy = [...cacheStrategies.entries()]
        .find(([pattern]) => path.includes(pattern))?.[1];

    if (strategy) {
        r.headersOut['Cache-Control'] = `max-age=${strategy.ttl}`;
        if (strategy.vary.length > 0) {
            r.headersOut['Vary'] = strategy.vary.join(', ');
        }
    }
}

Why This Changes the Game

For years, the choice was:

  • Simple NGINX: Fast but limited
  • NGINX + LUA: Powerful but complex
  • Proxy everything to backend: Simple but inefficient

Now we have a third option: NGINX + modern JavaScript. For someone like me, with almost 30 years working with different technologies, this is a game changer.

No more LUA. No more unnecessarily delegated logic to backend. Just JavaScript we already know.

Important Considerations

Before rushing to migrate everything:

Memory Management

# With context reuse enabled
http {
    js_engine qjs;
    js_context_reuse 128;  # Memory/performance balance

    # Don't store data in the global object
    # Use shared dictionaries for persistence
}

Backward Compatibility

# You can use both engines simultaneously
server {
    # Legacy scripts
    location /old {
        js_content legacy.handler;  # njs by default
    }

    # New scripts
    location /new {
        js_engine qjs;
        js_content modern.handler;
    }
}

The Future Ahead

According to NGINX, the plan is to eventually make QuickJS the default engine. This makes total sense: why maintain a custom ES5 engine when you have a complete ES2023 one?

For us infrastructure folks, this means:

  • One less language to learn (goodbye LUA)
  • More functionality at edge (less backend load)
  • More expressive configurations (JavaScript > declarative)
  • Better debugging (familiar tools)

Is It Worth the Switch?

For new cases: Absolutely. There’s no reason to start a new project with traditional njs.

For migrating existing ones: It depends. If you have simple njs scripts working, there’s no rush. But if you’re limited by ES5 or considering LUA, QuickJS is the answer.

To eliminate LUA: If you have logic in OpenResty that you could do in pure NGINX… now you can.


What do you think? Have you had to deal with njs limitations? Do you use LUA in OpenResty and are tired of it?

Personally, I think this will change how we design web architectures. Being able to do more at edge with a familiar language is a win-win. It was time for NGINX to catch up with modern JavaScript.

PS: If you want to experiment, you need njs 0.9.1 or higher. The official documentation about JavaScript engines is quite complete. Worth checking out.