JavaScript vs. C#: Five Differences behind Familiar Syntax

By Sergey Nosov

16 October 2026

Switching between JavaScript and C# can feel like switching countries. The same general idea of communication is there, but the grammar, the rules, and even the culture are different. What makes the trip tricky is how familiar the other country looks. Both languages put code in braces and declare classes. Both mark asynchronous code with async and await, write lambdas with =>, and guard against null with ?. and ??. Chrome, Firefox, and Node.js now even accept a using declaration.

Familiar syntax invites you to assume familiar behavior, and that assumption is where the bugs come from. A C# developer writing JavaScript may expect 7 / 2 to be 3, a method passed as a callback to remember its object, and fetch to throw on a 404 as GetStringAsync does. A Node.js developer writing C# may expect an unawaited failure to crash the process, and the garbage collector to close whatever the code no longer uses. Each expectation fails in the other language, and the last one fails in both.

This article walks through the five differences behind those surprises: typing, the object model, asynchronous code, resource cleanup, and tooling. For each one, it shows the trap that one language sets for someone fluent in the other, and where the two languages have moved toward each other. I ran the JavaScript samples on Node.js 24 and 26, checked the TypeScript with its 7.0 compiler, and ran the C# samples on .NET 10. A checklist of false friends closes the article.

Typing: Dynamic and Weak, Static and Strong

In JavaScript, a value has a type and a variable does not. MDN calls JavaScript “a dynamic language with dynamic types.” This runs without a complaint:

let value = 42;
// No error, no warning:
value = "Now I'm a string!";
// Prints: Now I'm a string!
console.log(value);

In C#, the variable has the type, and the compiler holds it to that type. Microsoft’s documentation calls C# “a strongly typed language” and says that “every variable, constant, and expression has a type.” The same assignment does not compile:

int value = 42;
// error CS0029: Cannot implicitly
// convert type 'string' to 'int'
value = "Now I'm a string!";

After JavaScript’s freedom, that feels restrictive, but it is a safety net. JavaScript is a wide-open field: you can steer anywhere, and you might end up in a ditch. C# is a road with guardrails: less freedom, but a whole class of mistakes stops at the compiler instead of reaching production.

Calling JavaScript loosely typed blurs two separate properties. JavaScript checks types only while the program runs, which makes it dynamically typed. It also converts between types on its own, which MDN calls weak typing: JavaScript “allows implicit type conversion when an operation involves mismatched types, instead of throwing type errors.” So "1" == 1 is true, and "6" * "7" is 42. In C#, both expressions fail to compile with error CS0019. In JavaScript, write ===, which compares without converting.

Numbers are where the difference bites first. JavaScript has one number type for ordinary arithmetic, a 64-bit IEEE 754 floating-point value, plus BigInt for integers of any size:

// Prints: 3.5
console.log(7 / 2);

// Prints: 9007199254740992
console.log(2 ** 53 + 1);

C# keeps integers and floating-point numbers apart, and arithmetic on two integers stays an integer:

// Prints: 3
Console.WriteLine(7 / 2);

int count = int.MaxValue;
count++;
// Prints: -2147483648
Console.WriteLine(count);

Microsoft’s rules explain both lines. Integer division returns the quotient “rounded toward zero,” and arithmetic on nonconstant expressions runs unchecked by default, so an overflowing int wraps around without an error. Wrap the code in checked, or set the CheckForOverflowUnderflow compiler option, and the overflow throws an OverflowException instead. JavaScript fails differently: past Number.MAX_SAFE_INTEGER, which is 2 ** 53 - 1, not every integer has an exact representation, and arithmetic quietly rounds.

C# offers an escape hatch, and has a false friend. A variable declared dynamic, available since C# 4.0, “bypasses static type checking,” so its errors surface at run time, as in JavaScript. var is not an escape hatch. In C#, var count = 42; declares an int: in Microsoft’s words, var does not mean “variant,” and “the compiler determines and assigns the most appropriate type.” In JavaScript, var declares a function-scoped variable of no fixed type; use let and const instead.

Even emptiness differs. JavaScript has two empty values. In MDN’s words, “undefined indicates the absence of a value, while null indicates the absence of an object.” The language gives you undefined for a missing property or a variable declared without a value. C# has only null. Since C# 8, its compiler can track which references might be null and warn before code dereferences one, and project templates have turned that analysis on since .NET 6. The results are warnings unless you treat them as errors.

TypeScript Brings Back the Guardrails, at Compile Time

Many JavaScript developers now write TypeScript: by GitHub’s count of monthly contributors, it overtook both Python and JavaScript in August 2025 to become the most used language on GitHub. Its lead architect, Anders Hejlsberg, is also, in Microsoft’s description, the “original designer of C#.” The TypeScript compiler rejects the same assignment, with error TS2322: “Type 'string' is not assignable to type 'number'.”

That brings the guardrails back, with two differences that matter after C#. The TypeScript handbook’s page for Java and C# programmers states both. The type system is “structural, not nominal”: an object fits an interface if it has the right shape, while a C# class must declare the interface, directly or through inheritance. And it is “not reified”: a TypeScript type “is not present in any form at runtime.” An as assertion tells the compiler what the data is, and nothing verifies it when the program runs:

interface User {
  name: string;
  age: number;
}

const json = `{
  "name": "Ana",
  "age": "forty"
}`;
// Compiles. Nothing checks it.
const user = JSON.parse(json) as User;

// TypeError: user.age.toFixed is
// not a function
console.log(user.age.toFixed(0));

The generics article on this site follows the same erasure into generic code, where C# keeps the type argument and TypeScript drops it.

C# types survive into the running program, so a deserializer can hold the data to them. Given the same text and a record User(string Name, int Age), System.Text.Json fails at the boundary, before any code uses the value:

// Throws JsonException: The JSON
// value could not be converted
// to User. Path: $.age ...
var user = JsonSerializer.Deserialize<User>(
    json, JsonSerializerOptions.Web);

That check converts what it can and refuses the rest. With JsonSerializerOptions.Web, the defaults ASP.NET Core uses, the string "40" would have become the number 40, but "forty" cannot become an int. By default, a missing member gets its default value: { "name": "Ana" } produces a user aged 0. Since .NET 9, the RespectRequiredConstructorParameters option turns that gap into an exception. Neither language replaces validation where data enters; the Boundary Defense article on this site shows how to do it.

Objects: Prototypes under the Class Syntax

In C#, an object’s members come from its type. JavaScript objects inherit from other objects. In MDN’s words, “Each object has an internal link to another object called its prototype,” and a property lookup that fails on the object continues along that chain of prototypes. Before ECMAScript 2015, the sixth edition of the standard, JavaScript had no class syntax. A class was written as a constructor function, with its methods attached to the prototype:

function Dog(name) {
  this.name = name;
}

Dog.prototype.bark = function () {
  console.log(this.name + " says woof!");
};

// Prints: Rex says woof!
new Dog("Rex").bark();

The C# class declares everything in one place:

public class Dog
{
    public string Name { get; }

    public Dog(string name)
    {
        Name = name;
    }

    public void Bark()
    {
        Console.WriteLine($"{Name} says woof!");
    }
}

Modern JavaScript writes the same dog with the class keyword, and the two versions now look alike. Underneath, little has changed. MDN says that classes in JavaScript “are built on prototypes but also have some syntax and semantics that are unique to classes.” The class is still a function, its instance methods still live on a prototype object, and the program can change that object while it runs:

class Dog {
  constructor(name) {
    this.name = name;
  }

  bark() {
    console.log(`${this.name} says woof!`);
  }
}

const rex = new Dog("Rex");

// Still a function underneath.
// Prints: function true
const proto = Object.getPrototypeOf(rex);
console.log(typeof Dog, proto === Dog.prototype);

// Add a method at run time, and
// every existing Dog has it.
Dog.prototype.sit = function () {
  console.log(`${this.name} sits.`);
};
// Prints: Rex sits.
rex.sit();

MDN draws the consequence: “It is possible to mutate any member of the prototype chain or even swap out the prototype at runtime, so concepts like static dispatching do not exist in JavaScript.” In C#, the compiler fixes a class’s members, and ordinary code cannot add a method to an existing class while the program runs. Extension methods look like additions, but Microsoft describes them as “static methods” that are “called as if they were instance methods,” and the compiler binds each call.

Methods Forget Their Objects

For a C# developer, the sharpest surprise is this. MDN states the rule: “The value of this in JavaScript depends on how a function is invoked (runtime binding), not how it is defined.” Take a method off its object, and the object is gone:

const bark = rex.bark;
// TypeError: Cannot read
// properties of undefined
// (reading 'name')
bark();

Class bodies always run in strict mode, so this is undefined in the detached call, and reading this.name throws. The object is lost wherever a method travels on its own, as an event handler or a callback. There, MDN says, this “depends on how the callback is called, which is determined by the implementor of the API.” Pass an arrow function that calls the method, or call bind:

// Prints: Rex says woof!
const bound = () => rex.bark();
bound();

In C#, turning a method into a delegate captures its object, so the method keeps working wherever it goes:

var rex = new Dog("Rex");

// The delegate keeps its object.
// Prints: Rex says woof!
Action bark = rex.Bark;
bark();

Privacy and Equality

A C# private member is enforced by the compiler, and System.Text.Json leaves it out of the JSON by default, though reflection can still read it. In TypeScript, the handbook says, private and protected “are only enforced during type checking.” In the running program, a TypeScript private field is an ordinary property that Object.keys lists and JSON.stringify writes out. JavaScript’s own private fields, written with a leading # and standardized in ECMAScript 2022, are different. MDN says their privacy “is enforced by JavaScript itself.”

Equality differs, too. JavaScript compares objects by identity, so two objects with the same contents are two members of a set:

const seen = new Set();
seen.add({ x: 1, y: 2 });
seen.add({ x: 1, y: 2 });
// Prints: 2
console.log(seen.size);

A C# record compares by value, and collections such as HashSet<T> use that equality:

var seen = new HashSet<Point>();
seen.Add(new Point(1, 2));
seen.Add(new Point(1, 2));
// Prints: 1
Console.WriteLine(seen.Count);

record Point(int X, int Y);

Microsoft’s rule for a class without its own equality members is the JavaScript one: “two objects are equal if they refer to the same object in memory.” Records, added in C# 9, are the short way to get value equality. Structs differ again: assigning a struct copies the whole value, so changing the copy leaves the original alone.

Async: One Thread or Many

These two functions look like twins. Each starts an HTTP request, waits for it without blocking, and returns the result; the JavaScript one also parses the JSON:

async function fetchData(url) {
  const response = await fetch(url);
  return response.json();
}
async Task<string> FetchDataAsync(string url)
{
    var response = await httpClient.GetStringAsync(url);
    return response;
}

Both start their work at once and hand back a placeholder: a Promise in JavaScript, a Task in C#. Both run synchronously up to the first await. There, JavaScript always pauses, even on a promise that has already settled, while C# carries on without pausing when the task has already finished. They also differ in where the rest of the function runs, and in what they do when the server says no.

Where the Rest of the Function Runs

JavaScript runs your code on one thread. An await returns control to the caller, and the rest of the function runs later, as a separate job on the same thread. MDN describes the guarantee: “Each job is processed completely before any other job is processed.” So between two awaits, no other job runs: only your code, and whatever it calls, touches your objects. Workers run in parallel, but as separate agents that receive copies of ordinary objects. Only a SharedArrayBuffer is shared, and agents synchronize through the Atomics object.

C# does not block a thread while it waits, either. Microsoft is explicit: “The async and await keywords don't cause extra threads to be created.”

But the rest of the method does not have to resume on the thread that started it. In a desktop app, the UI thread’s synchronization context brings it back to the UI thread. A console app or ASP.NET Core has no such context by default. There, after an await that actually waits, the rest of the method usually runs on a thread-pool thread. My test program started on thread 2 and continued on thread 5.

So JavaScript’s await means “wait your turn in line,” and C#’s can resume in a different lane, next to other code. Start two async calls before awaiting either, and JavaScript overlaps only their waiting. In C#, without a synchronization context, the code after each call’s first await can run on two pool threads at once. Here Task.Yield stands in for an await on unfinished work, such as an I/O call:

int counter = 0;

async Task BumpAsync()
{
    await Task.Yield();
    for (int i = 0; i < 1_000_000; i++)
        counter++;
}

// No Task.Run, yet on a multicore
// machine this printed less than
// 2000000: the loops overlapped.
await Task.WhenAll(BumpAsync(), BumpAsync());
Console.WriteLine(counter);

The same pair of calls in JavaScript prints two million every time. In C#, mutable state that concurrent async calls share needs a lock, an Interlocked operation, or a concurrent collection, whether or not anything calls Task.Run.

JavaScript is not free of races, either. Its ordinary objects cannot race between threads, but every await is a point where other code can run. This cache sits in front of an async loadUser function. When two calls overlap, it loads the same user twice, and the two callers get two different objects:

const cache = new Map();

async function getUser(id) {
  if (!cache.has(id)) {
    // Other calls run while this
    // one waits here.
    const user = await loadUser(id);
    cache.set(id, user);
  }
  return cache.get(id);
}

Cache the promise instead of the result, and every caller shares the load in flight:

const cache = new Map();

function getUser(id) {
  if (!cache.has(id)) {
    const load = loadUser(id).catch((error) => {
      // Forget a load that fails
      cache.delete(id);
      throw error;
    });
    cache.set(id, load);
  }
  return cache.get(id);
}

Like the first version, it keeps every user it has loaded; a real cache also needs a limit.

Blocking on a Task

C# lets you block on a task with .Result or .Wait(). JavaScript offers no way to block on a promise, which spares it the classic C# deadlock. On a UI thread, the rest of an async method runs, by default, on that same thread, and .Result keeps the thread busy waiting for it:

async Task<string> GetDataAsync()
{
    await Task.Delay(100);
    return "data";
}

// Never returns on a UI thread:
// the rest of GetDataAsync needs
// the thread .Result is blocking.
var data = GetDataAsync().Result;

I ran that block on a Windows Forms UI thread, and it was still blocked five seconds later. With ConfigureAwait(false) on the inner await, the same call completed, because the rest of the method no longer needed the UI thread. That avoids this deadlock, but it is not a general fix. Stephen Cleary calls using ConfigureAwait(false) to avoid deadlocks “a dangerous practice,” because every await below the blocking call would need it, including those in code you did not write.

ASP.NET Core has no synchronization context, so the call completes there too, but it holds a thread-pool thread for the whole wait. Microsoft warns that “many synchronous blocking calls lead to Thread Pool starvation and degraded response times.” Cleary’s 2013 guideline still holds: “Async all the way,” which means, in his words, that “you shouldn’t mix synchronous and asynchronous code without carefully considering the consequences.”

Errors Nobody Awaits

The two runtimes disagree most about a failure nobody awaits. Node.js ends the process:

async function save() {
  throw new Error("disk full");
}

// No await, no catch:
save();

// Never prints: Node.js has ended
// the process with exit code 1.
setTimeout(() => console.log("Still running"), 1000);

Since Node.js 15, the default is to raise an unhandled rejection as an uncaught exception unless the program handles the unhandledRejection event. .NET stays silent at run time:

// No await: the exception stays
// in the task, and nobody looks.
_ = SaveAsync();

await Task.Delay(1000);
// Prints: Still running
Console.WriteLine("Still running");

static async Task SaveAsync()
{
    await Task.Delay(10);
    throw new IOException("disk full");
}

Without the discard, the compiler warns with CS4014 that the call is not awaited; the discard says the omission is deliberate. At run time, the exception stays inside the task. If the process is still running when the garbage collector cleans up the faulted task, .NET raises the TaskScheduler.UnobservedTaskException event. Since .NET Framework 4.5, an unobserved exception no longer ends the process by default. My test program reported the event after a forced garbage collection and kept running.

The exception to the rule is async void. With no task to hold the exception, it is raised on the synchronization context the method started on, or on the thread pool when there is none. My console test had no context, and the exception crashed the process. Microsoft warns against async void, and the code review article on this site shows why.

My Software Development Principles entry on futures and promises treats an unhandled rejection as a logic error that a system should surface. Node.js surfaces it by default; in .NET, that is your job. Await every task, or log the failure of any task you deliberately let go.

When the Server Says No

Given a 404, the twins from the start of this section part ways. MDN says that a fetch() promise “does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.).” So the JavaScript twin went on to parse an HTML error page as JSON and failed with SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON.

The C# twin threw HttpRequestException: “Response status code does not indicate success: 404 (Not Found).” GetStringAsync, GetByteArrayAsync, GetStreamAsync, and GetFromJsonAsync check the status. GetAsync, SendAsync, and PostAsJsonAsync return the 404 response, and EnsureSuccessStatusCode turns it into the same exception. In JavaScript, check the status yourself:

async function fetchData(url) {
  const response = await fetch(url);
  if (!response.ok) {
    // Release the unread body
    await response.body?.cancel();
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

Neither a Promise nor a Task can cancel the work it stands for. Both platforms use a separate signal instead: an AbortSignal for fetch, and a CancellationToken for the .NET methods that accept one, in what Microsoft calls a model of cooperative cancellation. The cancel() call above only releases the unread body; that is cleanup, the next difference.

Cleanup: Memory Is Automatic, Resources Are Not

Both languages have a garbage collector, and both collectors free memory that nothing references anymore. The usual contrast goes like this. In JavaScript, you create objects, use them, and forget them, and the runtime eventually frees them. In C#, the same is true of memory, but resources such as database connections and file streams must be released explicitly. JavaScript babysits you; C# trusts you to be responsible.

The JavaScript half of that contrast holds only for memory. A garbage collector frees memory; it does not close a file, a socket, or a database connection on time, in either language. Microsoft’s guidance says that the .NET collector can track an object that wraps such a resource, but “it doesn't know how to release and clean up the unmanaged resource.”

In C#, a type that holds such a resource implements IDisposable, and a using statement calls its Dispose method when the block ends, even when an exception is thrown. Since C# 8, a using declaration does the same at the end of the enclosing scope, and await using handles types that clean up asynchronously:

static async Task<int> CountDatabasesAsync(
    string connectionString)
{
    // Disposed in reverse order
    // when the method exits, even
    // if a line below throws.
    await using var connection =
        new SqlConnection(connectionString);
    await connection.OpenAsync();

    await using var command =
        connection.CreateCommand();
    command.CommandText =
        "SELECT COUNT(*) FROM sys.databases";
    return (int)(await command.ExecuteScalarAsync())!;
}

Forget the using, and nothing fails at first. I tried to open three connections against a pool limited to two, keeping each one open without disposing it. The third Open waited out its three-second timeout, then threw: “Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool. This may have occurred because all pooled connections were in use and max pool size was reached.”

When I abandoned two connections without keeping a reference, the next Open still timed out; after I forced a garbage collection, it succeeded at once. Disposing a connection returns it to the pool without waiting for the collector. With the using declarations, ten calls in a row completed against the same limit of two.

JavaScript has the same problem with the same kinds of resources. A Node.js FileHandle that you never close may get closed during garbage collection. The documentation warns: “Please do not rely on this behavior because it can be unreliable and the file may not be closed.”

When the collector does close such a handle, Node.js 24 prints a deprecation warning, and Node.js 25 and later raise an uncaught error. In my test, Node.js 26 ended the process with exit code 1: “A FileHandle object was closed during garbage collection. This used to be allowed with a deprecation warning but is now considered an error.”

Other resources outlive their use, too. An interval timer keeps firing until it is cleared. In Node.js, read or cancel every fetch response body instead of leaving its connection to the garbage collector. The fetch implementation, undici, warns that relying on collection “can lead to excessive connection usage, reduced performance (due to less connection re-use), and even stalls or deadlocks when running out of connections.”

JavaScript now has C#’s answer, too. The explicit resource management proposal adds using and await using declarations. When the block ends, they call each object’s Symbol.dispose or Symbol.asyncDispose method, in reverse order and even after a throw. The proposal’s “Prior Art” section lists C#’s using statement and using declaration first. TC39 has advanced the proposal to Stage 4, and it is in the draft of ECMAScript 2027. Chrome 134, Firefox 141, and Node.js 24 already run it, and in Node.js a FileHandle can be disposed this way:

import { open } from "node:fs/promises";

async function appendLine(path, line) {
  await using file = await open(path, "a");
  await file.write(`${line}\n`);
  // file.close() runs here, even
  // if write() throws
}

Safari supports it only in its Technology Preview as of October 2026. For browsers without it, TypeScript has compiled using declarations down to older JavaScript since version 5.2, given a small polyfill for the new symbols. Its announcement made the comparison itself: “You might be familiar with using declarations in C#, with statements in Python, or try-with-resource declarations in Java.”

Neither keyword fixes the other kind of leak: memory that stays reachable. A long-lived event source keeps every forgotten listener alive in both languages, the lapsed listener problem that the Observer article describes. A cache that only grows is a leak in both. A garbage collector frees what is unreachable, not what is unused; the leaky abstractions article follows that thought further.

Tooling: A Kit You Assemble or a Kit in a Box

With JavaScript, you usually piece together a toolkit. You choose an editor such as VS Code and a package manager such as npm, Yarn, or pnpm. Then come a bundler (Vite, webpack, or Rollup), a test runner (Vitest, Jest, or Mocha), a linter, and often the TypeScript compiler. It is flexible and lightweight, and every project can feel like a different puzzle.

C# lives in a more unified ecosystem. The .NET SDK holds the compiler, MSBuild, the NuGet client, and the dotnet command, which builds, tests, and formats code. IDEs such as Visual Studio and JetBrains Rider add debugging, profiling, testing, and code analysis in one package. If JavaScript is Lego bricks picked from different sets, C# is the whole construction kit in one box: heavier, but everything fits together.

Microsoft retired Visual Studio for Mac on 31 August 2024, so Visual Studio itself now runs only on Windows. On macOS and Linux, C# developers use Rider, which JetBrains made free for noncommercial use in October 2024, or VS Code with Microsoft’s C# Dev Kit. Testing has changed, too, and the Microsoft.Testing.Platform article covers the new .NET test runner.

The deeper difference is the standard library. .NET ships HTTP, JSON, cryptography, and extensive collections in the box. JavaScript has JSON, Map, and Set built in; browsers and Node.js add fetch and Web Crypto, and Node.js adds file and network APIs. Still, JavaScript’s standard library is narrower, so npm fills the gap with packages, and dependency trees run deep.

In March 2016, after a dispute over another package’s name, an author unpublished 273 packages, among them a small one named left-pad. Builds that depended on it, directly or through other packages, failed across the ecosystem, and npm reported “hundreds of failures per minute.” Deep trees are also a supply-chain surface, and the Pragmatic Programmer article on this site covers the self-replicating npm worms of 2025 and 2026.

The two kits are moving toward each other. Node.js now ships a test runner (stable since version 20), a watch mode, and .env file loading. Since version 23.6, it also runs TypeScript files that use only erasable syntax. It strips the types, and in the documentation’s words, “no type checking is performed.” From the other side, .NET 10 runs a C# file with no project around it: dotnet run hello.cs does for C# what node hello.js does for JavaScript.

A Checklist of False Friends

Language learners call words that look alike in two languages but mean different things false friends. These are the ones to check when you switch:

Further Reading