Rajnish Noonia

Author: Rajnish Noonia

  • The JavaScript Execution Engine: Event Loop, Microtasks, Macrotasks, Web Workers and Service Workers

    JavaScript is single-threaded. There is one call stack, one thread of execution, and at any given moment exactly one thing is running. Yet it handles timers, network requests, user events, animations, background computation, and offline support without blocking. Understanding how it works changes how you write async code, debug race conditions, and reason about performance. This post covers the full picture: the event loop, the queues, Web Workers, and Service Workers.

    JS engine (single thread) Call stack Executes synchronous code console.log(‘hello’) main() Heap Object memory allocation { name: ‘obj’, val: 42 } Web APIs / Node.js setTimeout Timer (0ms+ delay) fetch / XHR Network I/O DOM events click, scroll, resize rAF / Promises Async coordination Microtask queue Drains completely before next task Promise.then() / .catch() / .finally() queueMicrotask() / MutationObserver Macrotask queue One task per loop iteration setTimeout / setInterval I/O callbacks / UI events Event loop offload Priority: (1) sync (2) microtask – drains fully (3) macrotask – one per iteration
    JavaScript execution engine: call stack, Web APIs, microtask queue, macrotask queue and event loop

    The Moving Parts

    Four components interact to make async JavaScript work on the main thread.

    The call stack

    The call stack is where synchronous code executes. When you call a function, a frame is pushed onto the stack. When it returns, the frame is popped. The engine can only execute code at the top of the stack. If a function takes 500ms of CPU work, nothing else can happen during those 500ms – the stack is blocked. This is why long-running synchronous operations freeze the browser: the event loop cannot process anything while the stack is occupied.

    Web APIs (or Node.js equivalents)

    The browser provides APIs that live outside the JavaScript engine: timers, network I/O, DOM events, file system access. When you call setTimeout, the JS engine hands the callback and delay to the browser’s timer mechanism and immediately returns. The actual waiting happens in a separate system thread managed by the browser. When the timer fires, the browser posts the callback into a queue.

    This is the key insight: JavaScript itself never does I/O. It delegates to the runtime, registers a callback, and moves on. The runtime notifies JavaScript when the work is done by placing callbacks into one of two queues.

    The microtask queue

    Microtasks are high-priority callbacks that run immediately after the current task completes, before the event loop picks up any macrotask and before the browser renders the next frame. Sources of microtasks include:

    • Promise.then(), .catch(), .finally()
    • queueMicrotask()
    • MutationObserver callbacks
    • await continuations (which desugar to Promise .then())

    The critical rule: the microtask queue drains completely before the event loop moves on. If a microtask callback queues another microtask, that microtask also runs before any macrotask. This can theoretically starve the macrotask queue – and the rendering pipeline – if microtasks keep spawning more microtasks.

    The macrotask queue (task queue)

    Macrotasks are lower-priority callbacks scheduled by the runtime. Sources include:

    • setTimeout and setInterval callbacks
    • I/O callbacks (file reads, network in Node.js)
    • UI event callbacks (click, keydown, scroll)
    • setImmediate in Node.js
    • MessageChannel port messages

    The event loop picks exactly one macrotask per iteration. After that one macrotask runs, it drains the entire microtask queue again before picking the next macrotask.

    The event loop algorithm

    while (true) {
      executeCurrentTask();
      while (microtaskQueue.length > 0) {
        const task = microtaskQueue.shift();
        task();
      }
      maybeRender();
      if (macrotaskQueue.length > 0) {
        const task = macrotaskQueue.shift();
        task();
      }
    }

    Why Promise.then() always beats setTimeout(fn, 0)

    setTimeout(fn, 0) does not mean “run immediately”. It means “run as soon as possible, but only after the current task and all microtasks have finished, and only as a macrotask”. A Promise.then() registered at the same time will always run first because it goes into the microtask queue, which drains before any macrotask is picked.

    setTimeout(() => console.log('timeout'), 0);
    Promise.resolve().then(() => console.log('promise'));
    
    // Output:
    // promise  (microtask - runs first)
    // timeout  (macrotask - runs after all microtasks)

    How fetch actually works

    When you call fetch('/data'):

    1. fetch() returns a Promise immediately. The browser’s networking layer starts the HTTP request in a separate thread.
    2. JavaScript continues executing synchronously – nothing waits.
    3. When the response arrives, the browser resolves the promise, queuing any .then() handlers as microtasks.
    4. The next time the microtask queue drains, those handlers run.
    5. Each .then() in a chain is a separate microtask, queued lazily when the previous one resolves.

    How async/await desugars

    async/await is syntactic sugar over promises. Every await suspends the async function (pops its frame off the call stack) and queues the continuation as a microtask when the awaited promise resolves.

    // async/await
    async function load() {
      const res = await fetch('/data');
      const data = await res.json();
      console.log(data);
    }
    
    // Is approximately:
    function load() {
      return fetch('/data')
        .then(res => res.json())
        .then(data => { console.log(data); });
    }

    A worked example: what does this log?

    console.log('A');
    setTimeout(() => console.log('B'), 0);
    Promise.resolve()
      .then(() => console.log('C'))
      .then(() => console.log('D'));
    console.log('E');
    
    // Output: A E C D B

    Sync runs first: A, then E. Microtask queue drains: C resolves and queues D, so C then D. Finally the macrotask runs: B. The second .then() was not enqueued until the first one ran – promise chains are lazy microtask sequences, not a pre-loaded batch.

    Main thread UI + event loop Call stack Sync execution DOM access Microtask queue Promise callbacks Macrotask queue setTimeout, events postMessage() Send / receive data Render pipeline Layout – Paint Blocked by JS Workers never block this Web Worker Separate OS thread Own call stack CPU-heavy work No DOM access Own microtask queue Promises work here Own macrotask queue setTimeout works here postMessage() Send results back Service Worker Network proxy thread Lifecycle events install – activate idle / terminated fetch event Intercepts all requests respondWith(promise) Cache API Serve offline / stale Cache-then-network Push + sync events Background wakeup No DOM / no window postMessage to page send result fetch() intercepted by SW cached / network response All three have their own event loop – only the main thread can access the DOM
    Main thread, Web Worker, and Service Worker – three independent event loops with distinct responsibilities

    Web Workers: true parallelism for JavaScript

    The event loop handles async waiting by delegating I/O to the browser and resuming when ready. But it does not solve CPU-intensive computation. If you need to parse a 50MB JSON file, run a physics simulation, encrypt a large payload, or process an image, doing that on the main thread blocks the call stack and freezes the UI regardless of how cleverly you structure your promises.

    Web Workers give you a genuine second OS thread. A Worker runs in a completely separate execution context with its own call stack, its own microtask and macrotask queues, its own memory heap, and its own event loop. The main thread and workers run in parallel on multi-core hardware.

    What Workers can and cannot do

    A Worker has access to: fetch, setTimeout, setInterval, Promise, IndexedDB, WebSockets, Cache API, crypto, console, and most Web APIs that don’t require a rendering context. What it does not have access to is the DOM – no document, no window, no direct manipulation of page elements. The rendering pipeline lives exclusively on the main thread, and the browser enforces this hard boundary.

    Communication via postMessage

    The main thread and workers communicate exclusively through postMessage(). Data is serialised (structured clone algorithm) and deserialised on the other side; each side gets its own copy, not a shared reference. For large data (images, audio buffers, typed arrays) you can use Transferable Objects to hand ownership to the other thread without copying, which is zero-copy and fast.

    // main.js
    const worker = new Worker('worker.js');
    worker.postMessage({ action: 'process', data: largeArray });
    worker.onmessage = (event) => {
      console.log('Result:', event.data);
    };
    
    // worker.js
    self.onmessage = (event) => {
      const result = heavyComputation(event.data.data);
      self.postMessage(result);
    };

    When a worker calls postMessage(), the message arrives on the main thread as a macrotask – it queues a message event on the worker object. The main thread’s event loop picks it up in the normal way: after all current microtasks have drained. This means worker communication is non-blocking in both directions.

    Transferable Objects for zero-copy transfer

    const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB
    worker.postMessage({ buffer }, [buffer]);
    // buffer is now detached in main - worker owns it
    
    self.onmessage = (e) => {
      processBuffer(e.data.buffer);
      self.postMessage({ buffer: e.data.buffer }, [e.data.buffer]);
    };

    Shared memory with SharedArrayBuffer

    For high-frequency communication (game engines, audio processing, real-time data pipelines), copying data on every message is too expensive. SharedArrayBuffer creates a memory region that both the main thread and workers can read and write without copying. You coordinate access using Atomics – atomic operations that prevent race conditions by guaranteeing visibility and mutual exclusion.

    const shared = new SharedArrayBuffer(4);
    const view = new Int32Array(shared);
    worker.postMessage({ shared });
    Atomics.store(view, 0, 1);
    Atomics.notify(view, 0, 1);

    Worker types

    There are three kinds of Worker: Dedicated Workers (owned by one page, created with new Worker()), Shared Workers (shared across multiple pages from the same origin, accessible via new SharedWorker()), and Service Workers (covered in the next section). Dedicated Workers are by far the most common.

    Service Workers: the network proxy

    A Service Worker is a type of worker with a fundamentally different purpose. Where a Dedicated Worker offloads CPU computation, a Service Worker sits between your application and the network, intercepting and handling every fetch request the page makes. It is the foundation of Progressive Web Apps (PWAs) – offline support, background sync, push notifications, and fine-grained caching strategies all live here.

    The Service Worker lifecycle

    A Service Worker has a distinct lifecycle that separates it from ordinary workers:

    1. Registration: the page calls navigator.serviceWorker.register('/sw.js'). The browser downloads and parses the worker script.
    2. Install: the browser fires the install event. This is where you pre-cache static assets. You call event.waitUntil(promise) to tell the browser not to proceed until your caching is complete. If the promise rejects, the install fails and the worker is discarded.
    3. Activate: once installed, the worker waits to activate. A new worker only activates when no existing controlled pages are open (or when you call self.skipWaiting()). The activate event is where you clean up old caches from previous versions.
    4. Idle: the worker is now active and controlling pages, but the browser may terminate it at any time to save memory. It is relaunched on demand when a controlled page makes a fetch or a push notification arrives.
    const CACHE = 'v1';
    const STATIC = ['/index.html', '/app.js', '/style.css'];
    
    self.addEventListener('install', event => {
      event.waitUntil(
        caches.open(CACHE).then(cache => cache.addAll(STATIC))
      );
      self.skipWaiting();
    });
    
    self.addEventListener('activate', event => {
      event.waitUntil(
        caches.keys().then(keys =>
          Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
        )
      );
      self.clients.claim();
    });

    Intercepting fetch requests

    The fetch event is the core of a Service Worker. Every network request made by a controlled page – including fetch(), XMLHttpRequest, CSS imports, image loads, script tags – passes through this handler. You use event.respondWith(promise) to provide the response, which can come from the cache, the network, or be constructed programmatically.

    self.addEventListener('fetch', event => {
      event.respondWith(
        caches.match(event.request).then(cached => {
          if (cached) return cached;
          return fetch(event.request).then(response => {
            const toCache = response.clone();
            caches.open(CACHE).then(cache => cache.put(event.request, toCache));
            return response;
          });
        })
      );
    });

    Caching strategies

    • Cache first: serve from cache if present, otherwise fetch from network and cache the result. Best for assets that change infrequently (fonts, versioned JS bundles).
    • Network first: try the network, fall back to cache if offline. Best for frequently updated content (API responses, news feeds).
    • Stale while revalidate: serve from cache immediately (fast), then fetch from network in the background to update the cache for the next request. Best for content where showing slightly stale data is acceptable.
    • Network only: never use the cache. For analytics, payment flows, anything where stale data is dangerous.
    • Cache only: never hit the network. For pre-cached static assets in a fully offline app.

    Background sync and push notifications

    Service Workers can be woken up by the browser even when no page is open. The sync event fires when connectivity is restored after a period offline – you can queue writes made while offline and flush them here. The push event fires when a push message arrives from your server, allowing you to display a notification even if the user doesn’t have your site open.

    self.addEventListener('sync', event => {
      if (event.tag === 'submit-form') {
        event.waitUntil(flushPendingSubmissions());
      }
    });
    
    self.addEventListener('push', event => {
      const data = event.data.json();
      event.waitUntil(
        self.registration.showNotification(data.title, {
          body: data.body,
          icon: '/icon.png',
        })
      );
    });

    Communicating with the page

    A Service Worker communicates with its controlled pages via postMessage, just like a Dedicated Worker. The worker can send messages to all controlled clients via self.clients.matchAll(), and individual pages can send messages to the worker via navigator.serviceWorker.controller.postMessage(). As with Dedicated Workers, these messages arrive as macrotasks on the receiving end.

    self.clients.matchAll().then(clients => {
      clients.forEach(client => client.postMessage({ type: 'CACHE_UPDATED' }));
    });
    
    navigator.serviceWorker.addEventListener('message', event => {
      if (event.data.type === 'CACHE_UPDATED') {
        showUpdateBanner();
      }
    });

    Web Worker vs Service Worker: when to use which

    The two are often confused because both are “workers that run off the main thread”. The distinction is purpose, not mechanism.

    Web Worker Service Worker
    Primary purpose CPU-intensive computation Network proxy and caching
    Lifetime As long as the page holds a reference Persists independently, browser controls termination
    Scope Single page All pages on the same origin
    fetch access Can make fetch calls Intercepts all fetch calls from the page
    DOM access No No
    Offline support No Yes, via Cache API
    Push notifications No Yes
    Background sync No Yes
    Typical use cases Image processing, data parsing, encryption, physics, ML inference Caching strategy, offline PWA, push, background sync

    Practical implications

    Long microtask chains can block rendering

    The browser cannot render a new frame until the microtask queue is empty. For large data processing, break work into chunks using setTimeout to yield to the rendering pipeline, or use a Web Worker to move the work off the main thread entirely.

    Service Workers require HTTPS

    Service Workers can intercept and modify any network request, which makes them powerful – and dangerous if compromised. Browsers only register Service Workers on HTTPS origins (and localhost for development). There are no exceptions.

    Service Workers are version-sensitive

    When you deploy a new Service Worker, users with the old version keep it until all their tabs are closed and reopened. skipWaiting() + clients.claim() force immediate takeover – useful during development but potentially disruptive if the new worker uses an incompatible cache structure. Design your activate handler to clean up old caches explicitly.

    Workers have their own event loops

    Both Web Workers and Service Workers have their own complete event loop – their own call stack, microtask queue, and macrotask queue. Promise, setTimeout, fetch, and async/await all work inside a worker the same way they work on the main thread. The only difference is the absence of the DOM and window APIs.

    Summary

    JavaScript’s concurrency model has three layers. The event loop handles async waiting on the main thread – sync code runs first, then all microtasks drain (Promises, await continuations), then one macrotask runs, then microtasks drain again. Web Workers handle CPU parallelism – genuine OS threads running their own event loops, communicating with the main thread via postMessage without blocking the UI. Service Workers handle network and persistence – a long-lived proxy thread that intercepts fetch requests, implements caching strategies, enables offline use, and receives push notifications and background sync events independently of any open page.

    Together they give JavaScript – a language with one main thread – a complete answer to async I/O, CPU parallelism, and network resilience.

  • .NET 8 Cryptography: AES-GCM, PBKDF2, and Secure Key Management

    Data encryption in financial systems is a regulatory and architectural baseline. With .NET 8 LTS (November 2023), the System.Security.Cryptography namespace has changed significantly: several APIs widely used in earlier .NET versions are now obsolete or removed, and the recommended patterns for symmetric encryption, key derivation, and asymmetric key transport have all been updated. This post covers the current .NET 8 approach to AES encryption (both CBC and GCM modes), PBKDF2 key derivation, RSA key exchange, and practical hybrid encryption — with guidance on what changed and why it matters for enterprise financial applications.

    What Changed from Earlier .NET Versions

    If you worked with .NET cryptography before .NET 6, several classes you may have relied on are now obsolete or removed in .NET 8:

    • RijndaelManaged — removed in .NET 8. Use Aes.Create() instead. RijndaelManaged throws PlatformNotSupportedException on .NET 8.
    • MD5CryptoServiceProvider, SHA1CryptoServiceProvider — deprecated. Use the static MD5.HashData() and SHA256.HashData() methods introduced in .NET 7.
    • PasswordDeriveBytes — obsolete. Use Rfc2898DeriveBytes (PBKDF2) with SHA-256 or SHA-512, or the new static Rfc2898DeriveBytes.Pbkdf2() method in .NET 6+.
    • RSACryptoServiceProvider — works but is a legacy Windows CAPI wrapper. Prefer RSA.Create() for cross-platform behaviour and modern padding support.

    Symmetric Encryption — AES in .NET 8

    AES is the standard for symmetric encryption. .NET 8 supports two modes that matter architecturally: AES-CBC (cipher-block chaining — confidentiality only) and AES-GCM (Galois/Counter Mode — authenticated encryption providing both confidentiality and integrity). For any new system, prefer AES-GCM: it detects ciphertext tampering before decryption, which AES-CBC cannot. AES-CBC without a separate MAC is vulnerable to padding oracle attacks.

    AES-GCM — Authenticated Encryption (Recommended)

    using System.Security.Cryptography;
    
    public static class AesGcmEncryption
    {
        public static (byte[] ciphertext, byte[] nonce, byte[] tag) Encrypt(
            byte[] plaintext, byte[] key)
        {
            var nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
            var ciphertext = new byte[plaintext.Length];
            var tag = new byte[AesGcm.TagByteSizes.MaxSize];
    
            using var aes = new AesGcm(key, AesGcm.TagByteSizes.MaxSize);
            aes.Encrypt(nonce, plaintext, ciphertext, tag);
    
            return (ciphertext, nonce, tag);
        }
    
        public static byte[] Decrypt(byte[] ciphertext, byte[] key, byte[] nonce, byte[] tag)
        {
            var plaintext = new byte[ciphertext.Length];
    
            using var aes = new AesGcm(key, AesGcm.TagByteSizes.MaxSize);
            aes.Decrypt(nonce, ciphertext, tag, plaintext);
    
            return plaintext;
        }
    }

    AES-CBC (where backward compatibility requires it)

    using System.Security.Cryptography;
    
    public static class AesCbcEncryption
    {
        public static (byte[] ciphertext, byte[] iv) Encrypt(byte[] plaintext, byte[] key)
        {
            using var aes = Aes.Create();
            aes.Key = key;
            aes.GenerateIV();
    
            using var ms = new MemoryStream();
            using var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write);
            cs.Write(plaintext);
            cs.FlushFinalBlock();
    
            return (ms.ToArray(), aes.IV);
        }
    
        public static byte[] Decrypt(byte[] ciphertext, byte[] key, byte[] iv)
        {
            using var aes = Aes.Create();
            aes.Key = key;
            aes.IV = iv;
    
            using var ms = new MemoryStream(ciphertext);
            using var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Read);
            using var output = new MemoryStream();
            cs.CopyTo(output);
            return output.ToArray();
        }
    }

    Key Derivation — PBKDF2 with SHA-256

    Never use a raw password as an encryption key. Password-based keys must be derived using a key derivation function that applies a computationally expensive hash to slow down brute-force attacks. PBKDF2 (Password-Based Key Derivation Function 2) is the NIST-recommended approach. In .NET 8, use the static Rfc2898DeriveBytes.Pbkdf2() method with SHA-256 and a minimum of 600,000 iterations — the 2023 OWASP recommendation for PBKDF2-HMAC-SHA256.

    using System.Security.Cryptography;
    
    public static class KeyDerivation
    {
        public static byte[] DeriveKey(string password, byte[] salt, int keyLengthBytes = 32)
        {
            return Rfc2898DeriveBytes.Pbkdf2(
                password: password,
                salt: salt,
                iterations: 600_000,
                hashAlgorithm: HashAlgorithmName.SHA256,
                outputLength: keyLengthBytes);
        }
    
        public static byte[] GenerateSalt(int length = 16)
            => RandomNumberGenerator.GetBytes(length);
    }

    For machine-to-machine encryption in production financial services, avoid password-derived keys entirely. Generate cryptographically random AES keys and store them in a managed key store — Azure Key Vault, AWS Secrets Manager, or an HSM. Key rotation, access control, and audit logs come for free from the platform rather than being built from scratch.

    Asymmetric Encryption — RSA in .NET 8

    RSA is used for key transport and digital signatures, not for bulk data encryption — it is orders of magnitude slower than AES and limited by key size in the amount of data it can encrypt directly. The standard architectural pattern is hybrid encryption: encrypt the data payload with AES-GCM using a freshly generated key, then encrypt that AES key with the recipient’s RSA public key.

    using System.Security.Cryptography;
    
    public static class RsaEncryption
    {
        public static RSA CreateKeyPair() => RSA.Create(4096);
    
        public static byte[] Encrypt(byte[] data, RSA publicKey)
            => publicKey.Encrypt(data, RSAEncryptionPadding.OaepSHA256);
    
        public static byte[] Decrypt(byte[] ciphertext, RSA privateKey)
            => privateKey.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
    
        public static byte[] Sign(byte[] data, RSA privateKey)
            => privateKey.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    
        public static bool Verify(byte[] data, byte[] signature, RSA publicKey)
            => publicKey.VerifyData(data, signature,
                   HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    }

    Hybrid Encryption Pattern

    The practical pattern for encrypting arbitrarily large payloads in financial systems combines both approaches — RSA for secure key transport, AES-GCM for the actual data — and uses CryptographicOperations.ZeroMemory to scrub key material from managed memory after use.

    using System.Security.Cryptography;
    
    public static class HybridEncryption
    {
        public static (byte[] encryptedKey, byte[] nonce, byte[] tag, byte[] ciphertext)
            Encrypt(byte[] plaintext, RSA recipientPublicKey)
        {
            var aesKey = RandomNumberGenerator.GetBytes(32);
            try
            {
                var (ciphertext, nonce, tag) = AesGcmEncryption.Encrypt(plaintext, aesKey);
                var encryptedKey = RsaEncryption.Encrypt(aesKey, recipientPublicKey);
                return (encryptedKey, nonce, tag, ciphertext);
            }
            finally
            {
                CryptographicOperations.ZeroMemory(aesKey);
            }
        }
    
        public static byte[] Decrypt(
            byte[] encryptedKey, byte[] nonce, byte[] tag,
            byte[] ciphertext, RSA recipientPrivateKey)
        {
            var aesKey = RsaEncryption.Decrypt(encryptedKey, recipientPrivateKey);
            try
            {
                return AesGcmEncryption.Decrypt(ciphertext, aesKey, nonce, tag);
            }
            finally
            {
                CryptographicOperations.ZeroMemory(aesKey);
            }
        }
    }

    Architectural Guidance

    • Prefer AES-GCM over AES-CBC for new systems. Authenticated encryption prevents bit-flipping and padding oracle attacks that AES-CBC alone cannot stop.
    • Never reuse a nonce with the same AES-GCM key. Nonce reuse in GCM completely breaks confidentiality — both the keystream and the plaintext become recoverable. Generate a fresh random nonce per message.
    • Use RSA-OAEP-SHA256 padding. PKCS#1 v1.5 padding is vulnerable to the Bleichenbacher adaptive chosen-ciphertext attack. OAEP is the current standard.
    • Use PBKDF2 with SHA-256 and ≥ 600,000 iterations for any password-derived key. For non-interactive M2M keys, use RandomNumberGenerator.GetBytes(32) and store in a managed secrets store.
    • Zero key material after use with CryptographicOperations.ZeroMemory. In long-running services, uncleared key bytes can persist in managed heap memory until the next GC cycle — and potentially be readable via memory dumps.
    • Store private keys outside the application. In production financial systems, RSA private keys belong in HSMs or platform key vaults (Azure Key Vault, AWS KMS). Never commit key material to configuration files or source control.
    • Use certificate-backed RSA where available. X.509 certificates provide key identity, expiry, and chain-of-trust that raw key bytes do not — essential for regulated environments such as capital markets platforms.
  • .NET 8 CLR Internals: RyuJIT, Dynamic PGO, and the Garbage Collector

    With .NET 8 LTS shipping in November 2023, it is worth revisiting what the Common Language Runtime actually does under the hood — not just conceptually, but with the specific advances that directly affect how you design high-throughput, low-latency applications. This post covers the CoreCLR execution model, the RyuJIT compilation pipeline, tiered compilation and Dynamic PGO, the .NET 8 garbage collector, and Native AOT, with a practical lens on financial platform and trading system engineering.

    CoreCLR — The .NET 8 Execution Engine

    The CLR — known as CoreCLR since .NET Core — is the managed execution engine for .NET. It provides the runtime environment in which compiled .NET code runs, regardless of operating system or CPU architecture. The major subsystems are the same as the original design, but the implementation has been rebuilt and significantly advanced across eight major releases.

    • Class Loader: Loads assemblies and types on demand. In .NET 8, AssemblyLoadContext replaces AppDomain as the isolation primitive — enabling unloadable plugin assemblies, hot-reloadable modules, and isolated component boundaries essential for extensible platform architectures.
    • RyuJIT: The cross-platform JIT compiler that translates CIL (Common Intermediate Language) to native machine code. In .NET 8, RyuJIT includes AVX-512 hardware intrinsic support, improved loop vectorisation, and better register allocation — producing faster native code, particularly for numeric and financial computation.
    • Tiered Compilation: Methods start as quickly compiled Tier-0 code. Hot methods are recompiled at Tier-1 with full optimisations. This minimises startup latency while achieving peak throughput on critical paths.
    • Dynamic PGO (Profile-Guided Optimisation): Introduced in .NET 6 and significantly improved in .NET 8. The runtime collects profiling data at Tier-1 and uses it to drive a third compilation pass — applying type specialisation and call-site inlining that cannot be determined ahead-of-time.
    • Garbage Collector: Generational, concurrent, server-aware. The .NET 8 GC includes DATAS (Dynamic Adaptation to Application Size) — covered below.
    • Exception Manager: Zero-cost exception model for the happy path. Structured exception handling overhead is only paid when an exception is actually thrown.
    • Thread Support: The .NET 8 ThreadPool uses work-stealing queues and adaptive IO thread management. Task, ValueTask, and async/await are all built on top of the thread pool scheduler.

    RyuJIT and Dynamic PGO

    Dynamic PGO is the most architecturally significant compilation advance in recent .NET versions. At runtime, the JIT records which concrete types flow through virtual call and interface dispatch slots. On the third compilation pass (Tier-2), it replaces those dispatch sites with direct calls guarded by a type check — turning a virtual dispatch into a predictable branch. For trading platforms with polymorphic data pipelines (order handlers, risk calculators, streaming market data processors), this can eliminate 10–30% of dispatch overhead with no source code changes.

    Dynamic PGO is enabled by default in .NET 8. You can verify its effect on specific hot paths using BenchmarkDotNet with the MemoryDiagnoser attribute and the DOTNET_TieredPGO=1 environment variable. For latency-sensitive services, profile under representative load before drawing conclusions — cold JIT behaviour can mask the steady-state gains.

    The .NET 8 Garbage Collector

    The GC is the most architecturally consequential runtime subsystem for high-throughput services. Understanding it shapes decisions about allocation rates, object lifetimes, pooling strategies, and latency SLAs long before you write a line of application code.

    Generations and the Large Object Heap

    The .NET GC is generational. Objects are allocated into Generation 0. Survivors are promoted to Gen 1, then Gen 2. Gen 2 collects infrequently and can trigger a full blocking pause — the primary latency risk in real-time financial services.

    Objects larger than 85,000 bytes land on the Large Object Heap (LOH). The LOH is collected alongside Gen 2 and is not compacted by default, accumulating fragmentation over long-running service lifetimes. The architectural mitigation is to avoid LOH allocations entirely on hot paths using ArrayPool<T>, MemoryPool<T>, and Span<T>-based APIs.

    DATAS — Dynamic Adaptation to Application Size

    New in .NET 8, DATAS allows the Server GC to dynamically resize heap sections based on live data rather than provisioning a fixed heap per CPU core at startup. For containerised deployments — where your service runs in a Kubernetes pod with a strict memory limit — this matters significantly: the GC no longer over-provisions heap relative to the container ceiling, reducing OOM risk and improving memory efficiency under variable load without manual GC configuration.

    Zero-allocation patterns in .NET 8

    The primary tool for reducing GC pressure on hot paths is avoiding allocations entirely. .NET 8 provides a mature set of primitives for this:

    • Span<T> and Memory<T>: Stack-allocated or pooled slices of contiguous memory. Zero heap allocation for parsing, serialisation, and buffer manipulation. Central to high-frequency data pipelines.
    • ArrayPool<T>.Shared: Rent and return byte buffers from a thread-local pool. Eliminates LOH allocations for large temporary arrays such as serialised market data frames or network payloads.
    • stackalloc with Span<T>: Allocate fixed-size buffers on the stack — no GC involvement. Useful for small fixed-width financial records, message headers, and cryptographic keys.
    • IMemoryOwner<T> and MemoryPool<T>: Lifetime-managed pooled memory for async pipelines — returned to the pool when the owning scope disposes.
    // Zero-allocation price parsing using Span<T> — no heap allocation on the hot path
    public static decimal ParsePrice(ReadOnlySpan<char> input)
    {
        return decimal.Parse(input, System.Globalization.NumberStyles.Any);
    }
    
    // Rent a buffer from the pool instead of allocating a new array
    public static void ProcessMarketDataFrame(int frameSize)
    {
        byte[] buffer = ArrayPool<byte>.Shared.Rent(frameSize);
        try
        {
            var span = buffer.AsSpan(0, frameSize);
            // ... process frame data without additional allocations
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }

    Native AOT in .NET 8

    Native AOT (Ahead-Of-Time compilation) is a first-class publish target in .NET 8. It compiles the application entirely to native machine code at build time — eliminating the JIT, the CLR startup overhead, and a significant proportion of runtime metadata. The result is a self-contained binary with sub-millisecond startup times and a substantially smaller memory footprint.

    Architectural trade-offs to evaluate before adopting Native AOT: it is well suited for serverless functions (Azure Functions, AWS Lambda) where cold-start latency is in the SLA, sidecar processes, and CLI tooling where a self-contained executable replaces a runtime dependency. It is not suitable for reflection-heavy frameworks, dynamic plugin loading via AssemblyLoadContext, or libraries relying on runtime type resolution — all of which require IL metadata that AOT strips.

    How the Execution Pipeline Works

    When you build a .NET 8 project, Roslyn compiles your C# source into CIL (Common Intermediate Language) — a CPU-independent instruction set stored in a portable executable alongside rich metadata (type definitions, method signatures, custom attributes). At runtime:

    1. The CoreCLR host (dotnet or the application executable) loads and initialises the runtime, applying any environment-based GC and threading configuration.
    2. The Class Loader reads the PE metadata and resolves assembly dependencies via AssemblyLoadContext, creating isolation boundaries for plugins and hot-reloadable components.
    3. On the first call to a method, RyuJIT compiles the CIL to Tier-0 native code and caches it in the JIT code heap.
    4. The call counter increments. Once the threshold is reached, the method is recompiled to optimised Tier-1 code. With Dynamic PGO active, profiling data drives a further Tier-2 pass applying type specialisation for the hottest dispatch sites.
    5. The Server GC manages object lifetime automatically across per-CPU heaps, collecting in parallel to maximise throughput while background threads minimise Gen 2 pause duration.

    Architectural Takeaways

    Understanding the .NET 8 runtime model informs architectural decisions that cannot be made correctly from framework documentation alone:

    • GC pause budgets must be part of SLA design for real-time services — not an afterthought. Instrument allocation rates in production using EventPipe and dotnet-counters before optimising.
    • Allocation-free hot paths using Span<T>, pooling, and stackalloc are the primary levers for achieving sub-millisecond P99 latency in financial data services. Framework-level throughput gains (Dynamic PGO, SIMD) are secondary to eliminating unnecessary allocations.
    • DATAS makes .NET 8 services significantly better-behaved in Kubernetes with memory limits — the GC now respects cgroup boundaries rather than provisioning heap against total host memory.
    • Native AOT changes the deployment model: smaller, faster-starting processes at the cost of dynamic loading capability — a meaningful trade-off in containerised, serverless, and edge topologies.
    • AssemblyLoadContext is the correct isolation primitive for plugin architectures in .NET 8. AppDomain is not available in CoreCLR.
  • Authorization Vs Authentication

    Authentication

    Authentication is the mechanism whereby systems may securely identify their users. Authentication systems provide an answers to the questions:

    • Who is the user?
    • Is the user really who he/she represents himself to be?

    Authentication is the process of obtaining identification credentials from a user ( such as name and password ), and validating those credentials against some authority. If the credentials are valid, the entity that submitted the credentials is considered an authenticated identity.

    ASP.NET implements authentication through authentication providers, the modules that contain the code to authenticate the requestor’s credentials. Following are built in ASP.Net authentication providers.

    • Windows Authentication Provider
    • Forms Authentication Provider
    • Passport Authentication Provider

    Authorization

    Authorization, by contrast, is the mechanism by which a system determines what level of access a particular authenticated user should have to secured resources controlled by the system.

    Authorization systems provide answers to the questions:

    • Is user X authorized to access resource R?
    • Is user X authorized to perform operation P?
    • Is user X authorized to perform operation P on resource R?

    Once an identity has been authenticated, the authorization process determines whether that identity has access to a given resource.

    ASP.NET implements authorization through authorization providers, the modules that contain the code to authorize access to a given resource. ASP.NET includes the following authorization modules.

    • File Authorization Provider
    • URL authorization

    Authentication and authorization are somewhat tightly-coupled mechanisms – authorization systems depend on secure authentication systems to ensure that users are who they claim to be and thus prevent unauthorized users from gaining access to secured resources.

    ASP.NET Membership (Providers)

    ASP.NET Roles (Providers)

     

  • Reactive Extensions (Rx) Data Streaming

    You have probably heard about Reactive Extensions, a library from Microsoft that greatly simplifies working with asynchronous data streams and allows to query them with LINQ operators.In my previous post I briefly discussed about loading data via entity framework asynchronously however it lacks getting chunks of data. This post demonstrates how to use Reactive Extensions for loading data from database asynchronously in chunks covering brief around Reactive extension library.

    Reactive extensions (Rx)
    The Reactive Extensions (Rx) is a library for composing asynchronous and event-based programs using observable sequences and LINQ-style query operators. Using Rx, developers represent asynchronous data streams with Observables, query asynchronous data streams using LINQ operators, and parameterize the concurrency in the asynchronous data streams using Schedulers. Simply put, Rx = Observables + LINQ + Schedulers.Data sequences can take many forms, such as a stream of data from a file or web service, web services requests, system notifications, or a series of events such as user input. Reactive Extensions represents all these data sequences as observable sequences. An application can subscribe to these observable sequences to receive asynchronous notifications as new data arrive. The Rx library is available for desktop application development in .NET. It is also released for Silverlight, Windows Phone 7 and JavaScript.

    Reactive programming allows you to turn those aspects of your code that are currently imperative into something much more event-driven and flexible.Reactive programming can be applied to a range of situations—from WPF applications to Windows Phone apps—to improve coding efficiency and boost performance.

    Code snippets to asynchronous load data via entity framework in batches of 200 records.

    public void LoadPostCodes()
    {
     btnStatus.Content = "Started";
     listBox1.Items.Clear();
     (from p in cx.MAS_PostCode select p)
     .ToObservable(Scheduler.NewThread)
     .Buffer(200)
     .ObserveOn(SynchronizationContext.Current)
     .Subscribe(ld =>
     {
     foreach (var item in ld)
     {
     ListBoxItem litem = new ListBoxItem();
     litem.Content = string.Format("{0} {1}", item.PC_PostCode, item.PC_Address1);
     listBox1.Items.Add(litem);
     listBox1.ScrollIntoView(litem);
     }
     button1.Content = listBox1.Items.Count.ToString();
     },
     () => { btnStatus.Content = "Finished"; }
     );
    }

    Have finished writing MVVM(Nano View model) based scenario & Rx implementations …

    Will write soon and publish code..

    Regards
    Rajnish