Let's Talk About It: Practical Bidirectional Native .NET<->Node Interop (English)

Let's Talk About It: Practical Bidirectional Native .NET<->Node Interop

Friday, 13 November 2026

//

23 minute read

Node can load a .NET assembly and call its C# methods without starting another process. C# can do the reverse by embedding Node and loading JavaScript modules. We'll use Node API for .NET for both.

Both runtimes run in the same process. The managed assembly example still needs an installed .NET runtime. A Native AOT addon is another way to package it, which we'll discuss later.

We'll build both directions around a practical example: adding auto-scheduling to an existing Node calendar application using our Mostlylucid.Packing library. Node calls C# to find and reserve appointments; a .NET application then calls the shared JavaScript formatter through embedded Node. We also measure the latency and memory tradeoffs against a gRPC sidecar.

The companion sample includes the complete projects and benchmark harness. I'll show the scheduling and interop calls here, with links to the setup code.

Why use C# inside an existing Node application?

We've already written the resource-packing rules in .NET: which staff qualify for a service, when equipment is needed, and how to reserve everything together. Calling that library from the Node app lets us keep those rules in one place.

We can load the library into Node or put it behind a service:

Design What the Node application does What we take responsibility for
In-process module Calls the C# assembly through node-api-dotnet Loading .NET and native dependencies inside the Node process
gRPC sidecar Sends a request to a separate .NET service Hosting, monitoring and communicating with another process

Loading the library into Node avoids the HTTP/2 round trip. A sidecar runs the engine in a separate process, so it can fail or restart independently of the web app. The app awaits its responses asynchronously, and several application processes can use the same service.

We'll measure both arrangements using the same C# implementation. That lets us compare call overhead and memory use without changing the scheduling algorithm.

The feature: suggest, preview, reserve

Alice wants gels. Beth wants a pedicure. They want to start together. Each appointment needs a qualified technician and the appropriate station. Alice also needs a drying lamp for part of her service.

The request passes through the existing backend:

sequenceDiagram
    participant Browser as Existing calendar UI
    participant Node as Existing Node backend
    participant Engine as C# packing engine
    Browser->>Node: Find appointments within this window
    Node->>Engine: FindTogether(window, services)
    Engine-->>Node: Candidate, assignments and revision
    Node-->>Browser: Suggested calendar events
    Browser->>Node: Accept this candidate
    Node->>Engine: TryHold(candidate, command ID)
    Engine-->>Node: Hold accepted or rejected
    Node-->>Browser: Show held appointments or refresh suggestions

A suggestion is provisional. Someone can take the slot while the user is looking at the calendar. The hold operation must therefore check current availability and acquire the entire resource bundle. Confirmation makes that reservation the accepted booking.

“Auto-scheduling” here means finding a feasible joint appointment under declared constraints. This example does not rearrange the whole day or promise a globally optimal schedule.

Run the working example

Use .NET 10 and Node 22. Keep mostlylucid.packing and mostlylucidweb as sibling checkouts, then run from mostlylucidweb/samples/NodePacking:

npm ci
npm run build
npm run booking

You should see:

alice: gels with sarah
beth: pedicure with anna
Both appointments confirmed.

The sample README has clone commands and instructions for using another checkout location. The sample builds the packing preview from source.

The sample uses 3 June 2030, a fixed clock and an in-memory store, so each run starts with the same availability. In an application, these would come from your booking data and the system clock.

1. Describe the resources the scheduler must respect

An empty calendar slot doesn't tell us whether a qualified technician and the right equipment are available. We need to check both, including how their use changes during the service.

Alice's gels appointment occupies these resources:

Offset from start Stage Resources occupied
0–15 minutes Prepare Sarah and manicure station
15–35 minutes Dry Manicure station and lamp
35–45 minutes Finish Sarah and manicure station
45–55 minutes Reset Manicure station

For example, this stage says the lamp is needed from minute 15 through minute 35:

new ServiceStage(
    "dry",
    TimeSpan.FromMinutes(15),
    TimeSpan.FromMinutes(20),
    ["station", "lamp"])

station and lamp are role names. The service definition says which capabilities qualify a resource for each role. The engine assigns resources to those roles, then checks their availability and remaining capacity.

Sarah is not occupied during drying. She is needed again for finishing. The station remains occupied after Alice's customer-facing appointment ends because it needs resetting. A search near closing time must fit that reset too.

The complete salon definition declares Sarah, Anna, two stations, a lamp, the services and a five-minute time grid. You can change those declarations to try different staffing or equipment constraints.

2. Ask the C# engine for a joint appointment

In C#, a calendar combines a definition with a reservation store:

var definition = SalonDefinition.Create();
using var store = new SqliteCalendarStore(":memory:", definition);
var calendar = Pack.Calendar(definition, store);

The sample's exported class also supplies the fixed clock. To find a start time for both customers, call:

var result = calendar.FindTogether(
    from, to,
    [new BookingRequirements("gels", "alice"),
     new BookingRequirements("pedicure", "beth")],
    limit: 1);

Here from and to are DateTimeOffset values. The result contains a candidate whose appointments share a start time, with resource assignments for each service. The search window bounds the full resource footprint, including reset stages.

limit: 1 requests one returned match. Check CandidatesComplete before interpreting an empty result: if the search stopped at its candidate limit, there may still be a valid appointment. Version identifies the reservation state used for the search.

The bridge keeps the CalendarMatch in C#. JavaScript receives its ID and the appointment fields needed for display. When the user accepts, the bridge looks up that match and reserves its resources. The browser doesn't supply the resource demands.

3. Expose that operation to Node

We use Microsoft.JavaScript.NodeApi and its generator, pinned to 0.9.36. Applying [JSExport] to our Salon class makes its public methods available as a Node module. The generator builds the binding code and TypeScript declarations. This is Microsoft's generated module workflow.

Load the generated module from its assembly path:

import { fileURLToPath } from 'node:url';
import dotnet from 'node-api-dotnet/net10.0';

const assembly = fileURLToPath(new URL(
    './PackingBridge/bin/Release/net10.0/PackingBridge.dll', import.meta.url
));
const { Salon } = dotnet.require(assembly);

PackingBridge.dll is a managed .NET assembly. The package hosts .NET inside Node and loads its exported methods. Calls cross between the JavaScript and .NET runtimes within that process.

Use dotnet.require() for this explicitly exported module. The separate dotnet.load() API is for loading arbitrary assemblies and accessing their public types through .NET namespaces. The JavaScript reference describes both.

For the tested npm version, the import is node-api-dotnet/net10.0. An older documentation example includes .js, which fails against this package's exports.

Pass typed objects across the boundary

The booking API returns C# objects directly. Its signature is:

public SearchOffer FindTogether(SearchWindow window)

The request is a small struct with named fields:

public struct SearchWindow
{
    public string Start { get; set; }
    public string End { get; set; }
}

On the Node side, the exported method accepts an object:

const session = new Salon(':memory:');
const offer = session.findTogether({
    start: '2030-06-03T10:00:00Z',
    end: '2030-06-03T12:00:00Z'
});
console.log(offer.appointments[0].technician);

The generator produces declarations for SearchWindow, SearchOffer, AppointmentView and ReservationResult. Its method declaration is now:

findTogether(window: SearchWindow): SearchOffer;

There is no serialized JSON string between this call and the C# method. The result has typed properties such as outcome, candidatesComplete, candidateId and appointments. Holds and confirmations also return typed results. The contracts file defines the exported fields. Date fields remain ISO strings so the calendar can consume them directly.

The TypeScript declarations describe the API at compile time. They do not validate arbitrary input from an HTTP request. The Node backend still needs to validate the window and the authenticated caller's service selections before invoking the engine.

Classes and structs have different costs

Node API for .NET copies structs by value. Reading the resulting JS properties doesn't call a .NET getter. We use structs for the request and small reservation replies.

The nested search result uses exported classes. A class is exposed through a wrapper around the managed object, so reading a property crosses into .NET. Removing JSON doesn't remove those calls, string conversions or allocations. The marshalling documentation explains the lifetime and conversion rules.

I tried making the nested search result a struct too, but the 0.9.36 generator failed to build its appointment-array conversion. The class result shown here builds and passes the tests. This is a limitation of the tested preview and contract shape, not a claim that struct results are generally unsupported.

Read the result once for the web application

The JavaScript wrapper preserves the convenient two-argument API used below. It also takes a snapshot of the returned properties into ordinary JS data. That reads each field once, including every appointment field, without JSON.stringify or JSON.parse.

The snapshot avoids repeated managed getter calls as the application builds calendar events. It also gives the HTTP layer enumerable data fields to serialize; sending a managed wrapper directly can omit properties exposed through its prototype. We test that the snapshot survives an ordinary HTTP-style JSON round trip. JSON at the browser boundary is separate from JSON between Node and .NET.

The wrapper's declarations refer to the generated C# contract types. Run npm run typecheck to check a TypeScript consumer, including expected errors for a wrong parameter type and an unknown result property. The preview declaration file contains an unresolved ValueType base for structs, so this check uses skipLibCheck for dependency declarations. Consumer field and argument types are still checked; it is not a clean check of the generator's entire declaration output.

4. Find a suggestion from the Node backend

With the wrapper loaded, the application code looks like this:

const salon = createSalon(':memory:');
const offer = salon.findTogether(
    '2030-06-03T10:00:00Z',
    '2030-06-03T12:00:00Z'
);

if (!offer.candidateId) {
    throw new Error(`No joint appointment: ${offer.outcome}`);
}

The [from, to] values come from the user's selected window in the existing app. The fixture has Alice's and Beth's service requirements built into the bridge; an application API would accept validated service/client selections as well.

The offer contains two appointments for display: Alice's gels with Sarah, and Beth's pedicure with Anna, both starting at 10:00 UTC. It also carries the candidate ID, revision and completeness flag. Finding that offer has not changed capacity.

Dates cross the boundary as ISO timestamps with offsets. Keep that meaning intact through the backend, and let the calendar's configured time zone handle display. On the June fixture date, 10:00 UTC is 11:00 in London.

5. Add suggested events to the existing calendar

Our calendar adapter maps the appointment projections into event objects:

export function toCalendarEvents(offer) {
    return (offer.appointments ?? []).map(appointment => ({
        id: `${offer.candidateId}/${appointment.clientId}`,
        title: `${appointment.clientId}: ${appointment.serviceId} (${appointment.technician})`,
        start: appointment.start,
        end: appointment.end,
        editable: false,
        extendedProps: { candidateId: offer.candidateId, state: 'suggested' }
    }));
}

Return those events alongside the offer from your existing scheduling endpoint. For a FullCalendar-based UI, adding the returned array is one call:

const preview = calendar.addEventSource(suggestion.events);

These are FullCalendar's documented event fields, added through its event-source API. The sample tests the event mapping. You'll need to connect your scheduling endpoint to the existing calendar UI.

The suggested events are non-editable because dragging one independently could break the joint booking. If the user changes the requested window, remove the old preview with preview.remove() and request a new offer.

The displayed appointment ends when the customer's service ends. The engine also reserves cleanup time, so the next booking must come from a new availability check even if the display appears to have a gap.

Connect to authoritative booking data

The sample owns its SQLite reservation state. To integrate it with an existing app, map that app's staff, services, availability and active commitments into the engine. Calendar events alone won't supply qualifications, equipment capacity or reset requirements.

Search and booking must use the same source of reservation data. If you write to the old booking database and a separate packing database independently, a failed write can leave them disagreeing. Choose which component owns reservations and how the other reads or updates them before adding scheduling to the app.

6. Hold the candidate, then confirm it

When the user accepts the preview, the backend calls:

const commandId = randomUUID();
const hold = salon.tryHold(offer.candidateId, commandId);
if (!hold.success) throw new Error(hold.reason);

const confirmation = salon.confirm(hold.reservationId, randomUUID());
if (!confirmation.success) throw new Error(confirmation.reason);

randomUUID comes from node:crypto. A command ID identifies an operation, so a retry of the same operation must reuse its ID. Generating a new ID for each retry asks for a new operation. Confirmation is a separate operation and gets a separate ID.

The bridge's underlying C# call is:

calendar.TryHold(commandId, "alice", match, TimeSpan.FromMinutes(5));

match is the retained engine result. The SQLite store acquires the linked demands in one transaction. If any required capacity is no longer available, the whole hold is rejected. A successful hold has a reservation ID that confirmation uses.

Our short example immediately confirms. An application could instead keep the hold while the user completes a review or payment step, then confirm or cancel it according to that flow.

In the longer demo, two connections search the same store before either reserves anything. The output shows what happens when both try to use the result:

First hold: true
Same command returns same reservation: true
Competing stale offer accepted: false
Sarah free during drying: true
Sarah free during finishing: false
Confirmed: true

This checks a stale offer using a repeatable sequence of calls. Concurrent processes would need a separate stress test. The integration test also checks that a service whose reset runs past closing time is rejected.

The fixture assumes Alice may book for Beth. Real caller authorization belongs in the existing app. Also, the tiny bridge retains only the latest offer per Salon instance: scope that instance to a booking workflow. Sharing it globally across users would let one search replace another user's offer. A production service needs a bounded, expiring offer store and explicit ownership.

Does removing JSON make the calls faster?

It removes serialization, but the replacement has costs too. To measure them, the benchmark harness now compares three paths:

  • Typed objects in-process, including reading every returned field into the JS snapshot.
  • JSON in-process, including C# serialization and JavaScript parsing.
  • A gRPC sidecar, carrying that JSON response in a protobuf message.

All three call the same C# search and build the same result model. The old FindTogetherJson method remains only for the comparison and the sidecar adapter. The application's booking path uses FindTogether and typed results.

The benchmark reads the full result on every path. Timing only the creation of a managed object wrapper would leave out work the calendar app has to do later. The harness checks the returned candidate and assignments across all three paths, and each measured search performs the feasibility checks again.

Latency and sequential throughput

Measured on an Apple M5, macOS ARM64, 32 GiB RAM, Node v22.15.0 and .NET SDK 10.0.201. The sidecar uses Grpc.AspNetCore 2.85.0; the Node client uses @grpc/grpc-js 1.14.7. Both .NET hosts use workstation GC.

Each of three fresh-process trials warms up each operation with 200 calls, then measures 1,500 calls at concurrency one. The three modes rotate through the run order. Each table entry is the median of that statistic across the trials. p50 is median call latency; p95 is the time within which 95% of a trial's calls completed.

Work per call Path p50 p95 Sequential calls/s
Echo a 1 KiB field Typed objects + JS snapshot 1.9 µs 3.0 µs 400,953
Echo a 1 KiB field JSON interop 1.2 µs 2.3 µs 583,449
Echo a 1 KiB field gRPC + JSON response 138.5 µs 178.5 µs 6,803
Find the joint appointment Typed objects + JS snapshot 621.1 µs 740.5 µs 1,580
Find the joint appointment JSON interop 574.8 µs 779.2 µs 1,678
Find the joint appointment gRPC + JSON response 717.7 µs 1100.2 µs 1,252

The typed search was slower at p50 in this fixture. It took about 621 µs, compared with 575 µs through JSON and 718 µs through gRPC. Within paired trials, reading the typed result added roughly 33–87 µs over the JSON version. The typed p95 was lower in the aggregated table, so the measurements don't support a blanket claim that either in-process path wins at every percentile.

Class wrappers and the explicit JS snapshot explain why avoiding serialization needn't be a speedup. We read fields through the interop layer and create JS objects for the application. For this small payload, the JSON encoder/parser pair competes well with that work. A different payload, a different DTO shape or improved value-type marshalling could change the result; those need their own measurements.

The echo control passes a 1 KiB text field through each path. Its typed version uses a value struct, then reads the returned field. Its JSON version passes and parses a JSON string. Even here, direct marshalling has work to do: it reads and copies the object fields. Removing JSON alone doesn't make the boundary free.

The scheduler's response is 495 bytes when represented as JSON. That is a size comparison for the typed result, not a claim that the typed path sends a 495-byte wire message.

What is the gRPC overhead?

The gRPC service calls FindTogetherJson over a reused plaintext HTTP/2 connection on loopback TCP. The client follows the channel-reuse guidance, so connection establishment happens before the measured calls. There is no TLS, proxy or remote network in this test.

Across paired trials, its search p50 was about 119–282 µs higher than JSON interop and 32–221 µs higher than the typed path. That includes protobuf handling, HTTP/2, thread scheduling and JavaScript callback/Promise handling. A fully typed protobuf response would have different serialization costs. These numbers describe the adapter built here, rather than a fixed overhead for gRPC.

The differences are small for a single calendar-button click. They matter more when the app searches repeatedly as constraints change or makes many engine calls per request. Keep calls at the level of a scheduling operation rather than fetching individual values through an RPC API.

This was a development-machine run with natural garbage collection and other system activity. The calls/s column reports sequential throughput at concurrency one. Server capacity under concurrent load, reservation writes and write contention need separate tests.

Memory: count the whole arrangement

RSS is the resident memory reported for a process. It includes runtime code, libraries, buffers and retained allocator memory as well as managed heaps.

Arrangement Initialized RSS RSS after workload
Typed objects + JS snapshot 107.1 MiB 128.8 MiB
JSON interop 107.3 MiB 124.9 MiB
gRPC + JSON response, combined 169.2 MiB 236.3 MiB

These are median snapshots after the first valid search and after the warmups and timed workloads. No forced V8 or .NET collection is performed. The baseline Node process was about 40 MiB RSS.

The typed path did not reduce RSS compared with JSON here. Removing text buffers can be offset by managed wrappers, copied objects and their lifetimes. An RSS snapshot cannot isolate the allocation cost of serialization.

For gRPC, the table adds the Node client's RSS to the sidecar's working set. Looking only at the client would hide most of the arrangement. Summed RSS can count shared pages more than once, and collection timing affects retained memory. These are not unique physical-memory, peak-memory or leak measurements.

The raw results retain each process's measurements separately. Containers, GC settings, load, payloads and a sidecar shared by several Node processes can change the comparison. Loading .NET inside Node still means running both V8 and the CLR, with their separate heaps and garbage collectors.

Synchronous calls still block Node's event loop

Both in-process search methods run synchronously on the calling Node thread. Returning a typed object doesn't change where the search runs. The gRPC client awaits a response while the work happens in the other process.

The harness schedules a 10 ms timer, then issues 100 searches. Additional timer delay was about 31–53 ms for typed objects, 30–33 ms for JSON interop, and 0–0.9 ms through gRPC. The burst consumes the returned data, including the typed snapshot or JSON parse, but omits the latency loop's per-call assertions and timing.

That burst would also delay unrelated callbacks on the same Node thread. Bound the search work and measure event-loop delay alongside request latency. If the engine moves to a worker thread or an asynchronous API, decide which thread executes the work and owns its state. A Promise around a synchronous call still runs it on the current thread.

Reproduce the measurements

npm run build:bench
npm run bench
npm run bench:summary

The runner starts and stops its own loopback sidecar, uses fresh clients, and checks result equivalence. Raw results include trial values, environment details and hashes of the measured assemblies. BENCH_TRIALS and BENCH_ITERATIONS configure the run.

These measurements cover searches and a control call. They do not benchmark reservation writes, TLS, a remote service, Native AOT, concurrent users, the browser or the full stylo.bot engine.

The reverse direction: C# calls our JavaScript module

The existing Node app also has JavaScript we might want to call from .NET. Both sample applications use the same booking formatter. The C# application loads it through an embedded Node runtime and passes objects directly.

var offer = salon.FindTogether(new SearchWindow
{
    Start = "2030-06-03T10:00:00Z",
    End = "2030-06-03T12:00:00Z"
});

var text = runtime.Run(() =>
    new BookingFormatter(runtime.Import(modulePath)).Format(offer));

modulePath points to format-booking.cjs. The BookingFormatter adapter creates JSObject and JSArray values from the typed offer, then invokes the module's format function through Node-API. This reverse adapter uses explicit object construction rather than the generated module's class wrappers. The copy happens in one place and uses no JSON serialization or JavaScript parsing.

The JavaScript formatter reads offer.appointments directly and returns:

format-booking.cjs: 11:00 London: alice: gels with sarah; beth: pedicure with anna

The call runs inside runtime.Run, on the JS environment thread. All JS objects remain within that callback. Only the converted .NET string leaves it. The adapter has a typed Format(SearchOffer) signature, while its implementation handles the native JS values.

Run it with npm run reverse. The embedding program handles platform creation and native-path selection. It uses Microsoft.JavaScript.LibNode 20.1800.203, the pairing tested here with Node API 0.9.36.

This time, the .NET application owns the process and hosts Node. Microsoft's embedding support remains experimental, with a version-sensitive native dependency and one platform per process. Verify compatible maintained runtime builds before deployment. A generic libnode binary doesn't necessarily supply this wrapper's embedding exports.

The reverse example verifies object passing and formatting. The benchmark above measures Node-to-.NET calls, so its timings don't describe embedded-Node performance.

Packaging the integration

The project file enables CopyLocalLockFileAssemblies so the managed dependencies accompany the DLL. The facade also resolves SQLite's native library explicitly. Those details were necessary in the tested Node host; .NET's native-loading documentation explains the mechanism.

A Native AOT .node addon could remove the separately installed .NET runtime requirement. Microsoft's C# Dev Kit example demonstrates that approach. The typed booking path avoids JSON serialization, but the packing dependency graph, generated bindings and SQLite assets still need separate AOT validation. This tutorial does not publish or measure that variant.

I'd start with the generated module for this calendar app if its searches remain short enough to run on the Node thread and I can package the native dependencies with the app. I'd use a sidecar if I need process isolation, independent upgrades or one engine shared by several clients. The measurements give us a local cost for that choice; the application requirements decide whether it's worth paying.

Both functional demos, reservation checks and benchmarks were executed on macOS ARM64. The calendar-event projection is tested, but no complete browser application, Linux/Windows run or production load test is claimed.

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.