LeetCode Solution: 10. Regular Expression Matching

Mastering Regular Expressions: A Deep Dive into LeetCode 10 with Dynamic Programming

Hey there, fellow coders! 👋 Are you ready to tackle one of LeetCode’s classic challenges that might seem daunting at first glance but becomes quite elegant with the right approach? Today, we’re diving into Problem 10: Regular Expression Matching.

This problem is a fantastic way to sharpen your dynamic programming (DP) skills and gain a deeper understanding of how regular expressions actually work under the hood. Don’t worry if regex feels like magic – we’re going to demystify it together!

The Puzzle: Regular Expression Matching

We’re given two strings: an input string s and a pattern p. Our goal is to determine if the pattern p completely matches the input string s.

The twist? The pattern p isn’t just plain text. It can contain two special characters:

  • '.': This dot matches any single character. Think of it as a wildcard for one character.
  • '*': This asterisk matches zero or more of the preceding element. This is where things get interesting!

Important Note: The match must cover the entire input string s, not just a part of it.

Let’s look at a few examples to solidify our understanding:

Example 1: Basic Mismatch

  • s = "aa", p = "a"
  • Output: false
  • Why? The pattern “a” only matches the first ‘a’ of “aa”. It doesn’t cover the entire string.

Example 2: The Power of *

  • s = "aa", p = "a*"
  • Output: true
  • Why? * means “zero or more of the preceding element,” which is ‘a’. By repeating ‘a’ once, the pattern becomes “aa”, matching the input. If s was “a”, it would also match (zero repetitions). If s was “aaa”, it would match (two repetitions).

Example 3: .* – The Ultimate Wildcard Combo

  • s = "ab", p = ".*"
  • Output: true
  • Why? . matches any single character. * means “zero or more” of whatever precedes it. So, .* means “zero or more of any character.” It can match “ab”, “a”, “”, “xyz”, anything!

The “Aha!” Moment: Intuition

When you see problems involving matching parts of strings, especially with wildcards or options like “zero or more,” your mind should start thinking about Dynamic Programming (DP). Why DP? Because the decision at any point (does s[i] match p[j]) often depends on the results of smaller subproblems.

Imagine trying to match s and p.

  • If p[j] is a regular character or . you just check if s[i] matches p[j] and then move on to matching the rest of s and p.
  • But what if p[j] is *? This is the tricky part. * gives us options:
    1. It could match zero occurrences of the character before it.
    2. It could match one or more occurrences of the character before it.

These multiple choices, and the fact that we can build up a solution from smaller matches, strongly hints at DP!

Our Strategy: Dynamic Programming Grid

We’ll use a 2D boolean array, let’s call it dp, where dp[i][j] will be True if the first i characters of s (i.e., s[0...i-1]) match the first j characters of p (i.e., p[0...j-1]). Otherwise, it will be False.

Let m be the length of s and n be the length of p. Our dp table will be (m+1) x (n+1).

Step 1: Initialize the DP Table

  • dp[0][0] = True: An empty string (s with 0 characters) always matches an empty pattern (p with 0 characters). This is our base case.

  • First Row (dp[0][j] for j > 0): This represents matching an empty string s with a non-empty pattern p.
    An empty string can only be matched by a pattern that allows zero characters. This typically happens with *.
    If p[j-1] is '*', then it can match zero of the preceding element (p[j-2]). In this scenario, dp[0][j] would be True if dp[0][j-2] was True. All other dp[0][j] would be False.

Step 2: Fill the DP Table

We’ll iterate through s (from i = 1 to m) and p (from j = 1 to n) to fill the table. For each dp[i][j], we consider two main scenarios for the current character p[j-1]:

Scenario 1: p[j-1] is NOT '*' (i.e., it’s a regular character or '.')

  • If p[j-1] matches s[i-1] (meaning p[j-1] == s[i-1] or p[j-1] == '.'), then s[0...i-1] matches p[0...j-1] only if s[0...i-2] matched p[0...j-2].
  • So, dp[i][j] = dp[i-1][j-1].
  • Otherwise (if p[j-1] doesn’t match s[i-1]), dp[i][j] = False.

Scenario 2: p[j-1] IS '*'

This is the trickiest part. The * implies “zero or more” of the preceding character (p[j-2]). Let’s call this preceding character prev_char_in_pattern = p[j-2].

We have two possibilities for how * can be interpreted:

  1. * matches zero occurrences of prev_char_in_pattern:

    • In this case, prev_char_in_pattern and the * effectively disappear from the pattern.
    • So, we need to check if s[0...i-1] matches p[0...j-3]. This state is represented by dp[i][j-2].
    • Thus, dp[i][j] = dp[i][j-2].
  2. * matches one or more occurrences of prev_char_in_pattern:

    • For * to match one or more times, prev_char_in_pattern must match the current character s[i-1]. (i.e., prev_char_in_pattern == s[i-1] or prev_char_in_pattern == '.').
    • If they match, s[i-1] is consumed by one instance of prev_char_in_pattern. We then need s[0...i-2] to match p[0...j-1] (the prev_char_in_pattern followed by * is still active, potentially matching more characters).
    • This state is represented by dp[i-1][j].
    • So, if prev_char_in_pattern matches s[i-1], then dp[i][j] can also be dp[i-1][j].
  • Combining these: dp[i][j] will be True if either of these conditions holds.
    dp[i][j] = dp[i][j-2] (zero occurrences)
    If prev_char_in_pattern matches s[i-1] (i.e., p[j-2] == s[i-1] or p[j-2] == '.'):
    dp[i][j] = dp[i][j] or dp[i-1][j] (one or more occurrences)

Step 3: The Result

After filling the entire dp table, the final answer will be in dp[m][n].

Let’s walk through an example: s = "aab", p = "c*a*b"

dp[i][j] “” c * a * b
“” T F T F T F
a F F F T T F
aa F F F F T F
aab F F F F F T
  • dp[0][0] is T.
  • dp[0][2] (for c*): p[1] is *, so dp[0][2] = dp[0][0] which is T. (empty string matches c* by c repeating 0 times).
  • dp[0][4] (for c*a*): p[3] is *, so dp[0][4] = dp[0][2] which is T. (empty string matches c*a* by a repeating 0 times).
  • Now, consider dp[1][1] (s="a", p="c"): p[0] (‘c’) != s[0] (‘a’). So F.
  • Consider dp[1][4] (s="a", p="c*a*"):
    • p[3] is *. Preceding char p[2] is ‘a’.
    • dp[1][2] (zero ‘a’s for a*) is F.
    • Does p[2] (‘a’) match s[0] (‘a’)? Yes!
    • dp[0][4] (one or more ‘a’s) is T.
    • So dp[1][4] becomes F OR T = T.
  • Finally, dp[3][6] will be T.

The Code

class Solution(object):
    def isMatch(self, s, p):
        """
        :type s: str
        :type p: str
        :rtype: bool
        """
        m, n = len(s), len(p)

        # dp[i][j] will be True if s[0...i-1] matches p[0...j-1]
        # Dimensions: (m+1) x (n+1)
        dp = [[False] * (n + 1) for _ in range(m + 1)]

        # Base case: Empty string matches empty pattern
        dp[0][0] = True

        # Initialize the first row (s is empty)
        # An empty string 's' can only match patterns like "a*", "a*b*", etc.
        # This means the pattern must end with '*' and its preceding element can be skipped.
        for j in range(1, n + 1):
            if p[j - 1] == '*':
                # If p[j-1] is '*', it means zero occurrences of p[j-2].
                # So, dp[0][j] depends on whether p[0...j-3] matched an empty string.
                dp[0][j] = dp[0][j - 2]

        # Fill the DP table for all other cells
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                # Scenario 1: Current pattern character is a regular character or '.'
                # (p[j-1] is not '*')
                if p[j - 1] == s[i - 1] or p[j - 1] == '.':
                    # If current characters match, depend on the match of preceding substrings
                    dp[i][j] = dp[i - 1][j - 1]

                # Scenario 2: Current pattern character is '*'
                elif p[j - 1] == '*':
                    # Case A: '*' matches zero occurrences of the preceding element (p[j-2])
                    # We effectively ignore p[j-2] and p[j-1] (the '*')
                    # So, s[0...i-1] must match p[0...j-3]
                    dp[i][j] = dp[i][j - 2]

                    # Case B: '*' matches one or more occurrences of the preceding element (p[j-2])
                    # This is only possible if the preceding character (p[j-2]) matches s[i-1]
                    # (p[j-2] == s[i-1] or p[j-2] == '.')
                    if p[j - 2] == s[i - 1] or p[j - 2] == '.':
                        # If they match, '*' consumes s[i-1], and we check if s[0...i-2] matches p[0...j-1]
                        # (where p[j-1] is still '*', allowing it to match further characters)
                        dp[i][j] = dp[i][j] or dp[i - 1][j]

        return dp[m][n]

Time and Space Complexity Analysis

  • Time Complexity: O(m * n)

    • We are filling an m x n DP table. Each cell calculation takes constant time (a few comparisons and assignments). Therefore, the total time complexity is proportional to the product of the lengths of the string s and pattern p.
  • Space Complexity: O(m * n)

    • We use a 2D dp array of size (m+1) x (n+1) to store our intermediate results. This contributes O(m * n) to the space complexity.

Given the constraints 1 <= s.length <= 20 and 1 <= p.length <= 20, an O(m*n) solution is perfectly efficient for this problem. 20 * 20 = 400 operations per test case is lightning fast!

Key Takeaways

  1. Dynamic Programming for String Matching: Problems involving matching substrings, especially with wildcards or varying length matches, are prime candidates for DP.
  2. Defining DP State: Clearly defining dp[i][j] as the match for prefixes s[0...i-1] and p[0...j-1] is crucial.
  3. Handling Special Characters (. and *):
    • . is straightforward: it matches any single character.
    • * requires careful consideration: it has two main interpretations (“zero occurrences” OR “one or more occurrences”) that lead to combining results from different DP states.
      • Zero occurrences: The * and its preceding character are skipped (dp[i][j-2]).
      • One or more occurrences: The * matches the current character s[i-1] (if the preceding char allows it), and the * pattern remains active (dp[i-1][j]).
  4. Base Cases: Correctly initializing dp[0][0] and the first row (dp[0][j]) for an empty string s is vital for the DP to propagate correctly.

This problem is a fantastic way to stretch your DP muscles and see how complex string operations can be broken down into manageable subproblems. Keep practicing, and you’ll master these patterns in no time!

Published by p1Hzd8mRM8 on 2026-05-21 16:52:13

IPv4 Geolocation and Leasing: A Practical Guide for Network Operators

Geolocation questions come up regularly when operators start leasing IPv4 blocks. Does the subnet show up in the right country? How long until databases reflect the correct location? What’s the fix if something is wrong?

The short answer: for most infrastructure workloads, geolocation is irrelevant. For a specific set of use cases, it requires deliberate setup. This post covers both.

Two Separate Systems Worth Distinguishing

“Geolocation” in the IPv4 context actually refers to two different things, and confusing them leads to wasted troubleshooting time.

RIR-registered location – what’s recorded in the RIPE database via the geofeed: and geoloc: attributes on your inetnum object.

Commercial database location – what services like MaxMind, IPinfo, or Google display when they query your block. These databases pull from multiple signals:
RIR records, BGP routing data, latency measurements, user corrections. They operate independently and update on their own schedules.

When operators ask whether their subnet will appear as a specific country, they’re almost always asking about the second category. The answer depends on which database, when it last crawled your block, and what signals it weighted.

RIPE Attributes: What Actually Works

Three attributes are relevant here, and they’re not equally useful.

geofeed: – links to a structured CSV file (RFC 8805 format) hosted at a public HTTPS URL. Providers supporting RFC 9632 discovery ingest it automatically.

This is the mechanism with real adoption among commercial database providers and the recommended starting point.

geoloc: – records latitude/longitude coordinates directly in the RIPE database. Limited adoption among third-party providers. Worth setting, but don’t rely on it to influence MaxMind or similar databases.

country: – an administrative registration field. RIPE’s own documentation notes it was never formally specified what this field represents. Not a geolocation signal. Don’t treat it as one.

RIPE doesn’t verify any of this data. Publishing a correctly configured geofeed gives providers a structured, crawlable source – but each provider acts on it according to their own update cycle.

Use Cases Where Geolocation Is Operationally Critical

VPN and proxy services – if you’re selling a country-specific endpoint, the IP block needs to register as that country in the databases your users’ apps query.

Programmatic advertising – ad exchanges categorize traffic geographically. European inventory carries different pricing from traffic that appears to originate outside the EU. Miscategorized geolocation means mispriced inventory.

CDN configuration – routing decisions are based on IP location. A subnet appearing in the wrong region routes users to suboptimal edge nodes.

GDPR and data localization compliance – infrastructure processing EU data sometimes needs to be clearly identifiable as EU-based. An IP block appearing outside the EU creates compliance friction regardless of where the servers physically sit.

Web scraping and data collection – many platforms serve differentiated content, pricing, and availability by detected country. Your IP’s geolocation determines the geographic view your infrastructure gets.

Use Cases Where It Doesn’t Matter

General hosting, application servers, ISP infrastructure, internal networking, standard web traffic delivery – geolocation has no operational effect on any of it.

BGP routing operates on routing tables. Email deliverability depends on reputation signals – spam history, blacklist status, authentication records – not on geolocation.
A subnet geolocated in Amsterdam and one geolocated in Paris are treated identically by receiving mail servers.

Propagation Timelines

After lease activation, there’s a normal window before all databases reflect the correct location.

MaxMind – the most widely used commercial database – updates the majority of its GeoIP databases every weekday. Correction requests via the GeoIP Exchange program (where geofeed submission is now the standard method) don’t come with a committed review timeline.

Downstream services – CDN networks, ad exchange databases, streaming platform geolocation layers – typically take two to six weeks to fully propagate a change.

This is expected behavior, not a problem to solve.

If your deployment is geolocation-sensitive, build in a one-to-two week buffer after activation before relying on correct location data in production. For ad tech or CDN-critical workloads, two to four weeks is the safer margin.

Geolocation vs. Reputation: Not the Same Thing

These get conflated more than any other pair of concepts in IPv4 operations.

Geolocation – where your block appears to be located.
Reputation – the behavioral history associated with that block: spam records, blacklist entries, abuse flags.

Completely separate systems. A block can have accurate geolocation and a problematic reputation record, or an outdated geolocation entry and a perfectly clean history. Correcting one has no effect on the other.

If you’re evaluating a subnet before leasing, run a reputation check. Not a geolocation lookup.

For a more detailed breakdown of how this applies to IPv4 leasing specifically, including how IPbnb handles geolocation validation at the listing stage, the full guide is on our blog: IPv4 Geolocation and Leasing – IPbnb

Reconciling the Inefficiencies of Global Crypto Payments Platforms

The Problem We Were Actually Solving

Our platform store integration used a popular crypto payment gateway, which claimed to support over 150 cryptocurrencies. However, the actual support varied greatly depending on the country of the customer. For instance, users in the United States could receive payouts in their preferred currency within hours, while those in South Africa or India were stuck with a 7-10 day delay. We were losing customers due to these inconsistent payout timelines, and our business was suffering as a result.

What We Tried First (And Why It Failed)

Initially, we attempted to optimize our existing integration to improve the payout timelines. We added retry mechanisms, optimized network requests, and even implemented a queue-based system to process payments asynchronously. However, despite these efforts, the issue persisted. We realized that the root cause lay not with our implementation, but with the payment gateway’s architecture.

The Architecture Decision

We decided to take a radical approach and automate our order fulfillment directly with the blockchain. Instead of relying on the payment gateway, we would create our own smart contracts to handle payments and transfers. This would give us complete control over the payout process, allowing us to respond to customer requests in real-time. We knew it wouldn’t be an easy task, but we were willing to take the risk.

What The Numbers Said After

After implementing our new system, we saw a significant improvement in payout timelines. The median payout time reduced from 24 hours to just 1 hour for US customers, and to 3 hours for international customers. Our customers were finally able to receive their payouts in a timely manner, reducing frustration and increasing overall satisfaction. We also noticed a significant increase in customer retention, with a 25% reduction in churn rates.

What I Would Do Differently

In retrospect, I would recommend a more gradual approach to automating order fulfillment. While our radical change worked, it introduced a new set of challenges, such as higher development and maintenance costs. I would suggest starting with small-scale pilots to validate the new architecture before scaling up. By doing so, we could have mitigated some of the risks and ensure a smoother transition for our customers. Nonetheless, the lessons learned from this experience have been invaluable, and we continue to refine our system to accommodate the evolving needs of our customers.

I Exported HT-Demucs FT to ONNX in 2026 (4 Blockers Everyone Else Gave Up On)

You can’t ship htdemucs_ft on iOS. You can’t ship it on Android. You can’t run it in a browser. PyTorch Mobile is a 2 GB install and a permission to break, MLX needs Apple Silicon, and the obvious answer — ONNX — has been “broken on htdemucs” across four open GitHub issues for three years.

I just shipped the first working ONNX export of htdemucs_ft. It runs in onnxruntime, is 1.31× faster than PyTorch on CPU, and is numerically equivalent to the original (max absolute difference: 0.000163 on drums, 0.000008 on vocals). All four specialist sub-models are on Hugging Face: StemSplitio/htdemucs-ft-onnx.

This is the engineering writeup — the 4 blockers that killed every prior attempt and the patches that beat them.

What You’ll Learn

  • ✅ Why htdemucs is so hard to export (complex tensors, Python dynamism, fused C++ kernels)
  • ✅ How to replace torch.stft with Conv1d without losing accuracy (5 × 10⁻⁶ round-trip diff)
  • ✅ How to patch fractions.Fraction, random.randrange, and aten::_native_multi_head_attention
  • ✅ How to verify parity to 4 decimals before trusting the export
  • ✅ Pure numpy + onnxruntime inference — no PyTorch at runtime

Prerequisites

pip install "torch>=2.4,<2.5" "torchaudio>=2.4,<2.5" demucs onnx onnxruntime numpy soundfile

No GPU required for export or inference. Tested on Apple M4 Pro and Linux x86_64. If you don’t have demucs set up yet, follow the complete demucs local setup guide first.

Why doesn’t htdemucs export to ONNX out of the box?

Short answer: Because the model uses four PyTorch features that no ONNX exporter has good answers for — complex tensors in the STFT, fractions.Fraction arithmetic in model.segment, random.randrange inside the cross-transformer, and the fused C++ aten::_native_multi_head_attention kernel.

Each one stops torch.onnx.export and torch.onnx.dynamo_export cold. You hit them in order; each new patch unblocks the next failure.

Blocker 1: complex64 STFT output

Short answer: Replace torch.stft with a Conv1d using sin/cos kernels that emit two real-valued channels.

The first op in HT-Demucs is:

z = torch.stft(x, n_fft=4096, hop_length=1024, window=hann,
               win_length=4096, normalized=True, center=True,
               return_complex=True, pad_mode="reflect")

return_complex=True returns a complex64 tensor. ONNX’s STFT op (opset 17+) does not support complex outputs; every downstream slice/transpose fails. The workaround:

import math, torch
import torch.nn as nn
import torch.nn.functional as F

def _make_stft_kernels(n_fft: int):
    n = torch.arange(n_fft, dtype=torch.float64)
    window = torch.hann_window(n_fft, periodic=True, dtype=torch.float64)
    norm = 1.0 / math.sqrt(n_fft)
    k = torch.arange(n_fft // 2 + 1, dtype=torch.float64).unsqueeze(1)
    angles = 2 * math.pi * k * n.unsqueeze(0) / n_fft
    cos = (window * torch.cos(angles)) * norm
    sin = (window * -torch.sin(angles)) * norm   # negative for forward STFT
    return cos.float().unsqueeze(1), sin.float().unsqueeze(1)

class RealSTFT(nn.Module):
    def __init__(self, n_fft=4096, hop_length=1024):
        super().__init__()
        cos, sin = _make_stft_kernels(n_fft)
        self.register_buffer("cos_kernel", cos)
        self.register_buffer("sin_kernel", sin)
        self.n_fft, self.hop_length = n_fft, hop_length

    def forward(self, x):
        x = F.pad(x.reshape(-1, 1, x.shape[-1]),
                  (self.n_fft // 2,) * 2, mode="reflect")
        real = F.conv1d(x, self.cos_kernel, stride=self.hop_length)
        imag = F.conv1d(x, self.sin_kernel, stride=self.hop_length)
        return torch.stack([real, imag], dim=1)    # (BN, 2, F, T) real

Verify against the real thing before going further:

x = torch.randn(1, 343980)
ref = torch.stft(x, n_fft=4096, hop_length=1024,
                 window=torch.hann_window(4096), win_length=4096,
                 normalized=True, center=True, return_complex=True,
                 pad_mode="reflect")
ref_real = torch.stack([ref.real, ref.imag], dim=1)

stft = RealSTFT()
out = stft(x).squeeze(0)
print("max abs diff:", (out - ref_real).abs().max().item())   # ~5e-6

5 × 10⁻⁶ is rounding noise. Use the same trick (ConvTranspose1d with conjugate kernels + overlap-add window-squared envelope) for the inverse STFT. Now every view_as_real / view_as_complex in the model’s _magnitude and _mask methods can be rewritten to thread real-channel tensors through the whole forward pass.

Blocker 2: fractions.Fraction in model.segment

Short answer: Coerce to float before exporting.

Pretrained htdemucs_ft ships with model.segment = Fraction(39, 5) (= 7.8 seconds). Dynamo dies:

torch._dynamo.exc.Unsupported: call_function
UserDefinedClassVariable(<class 'fractions.Fraction'>)

Fix:

from fractions import Fraction

if isinstance(model.segment, Fraction):
    model.segment = float(model.segment)   # 7.8

Mathematically identical at inference. Trivial — but you don’t get to the next blocker without it.

Blocker 3: random.randrange in the cross-transformer

Short answer: Monkey-patch the affected method to hardcode shift=0.

CrossTransformerEncoder._get_pos_embedding calls Python’s random:

shift = random.randrange(self.sin_random_shift + 1)

At inference, sin_random_shift = 0, so random.randrange(1) always returns 0 — a no-op. But neither exporter can see through random and both bail out. Patch the method directly:

import types
from demucs.transformer import CrossTransformerEncoder, create_sin_embedding

def _get_pos_embedding_no_random(self_, T, B, C, device):
    if self_.emb == "sin":
        return create_sin_embedding(T, C, shift=0, device=device,
                                    max_period=self_.max_period)
    raise RuntimeError(f"emb={self_.emb} not handled at export")

for m in model.modules():
    if isinstance(m, CrossTransformerEncoder):
        m._get_pos_embedding = types.MethodType(_get_pos_embedding_no_random, m)

Blocker 4: aten::_native_multi_head_attention

Short answer: Replace nn.MultiheadAttention.forward with a plain Linear/bmm/softmax implementation.

Modern PyTorch short-circuits nn.MultiheadAttention.forward to a fused C++ kernel (_native_multi_head_attention) when its preconditions are met. That kernel has no ONNX symbolic at any opset:

torch.onnx.errors.UnsupportedOperatorError: Exporting the operator
'aten::_native_multi_head_attention' to ONNX opset version 17 is not supported.

Patch every MHA instance’s forward with a drop-in that uses only ops with stable ONNX symbolics:

def _onnx_friendly_mha_forward(self_, query, key, value,
                                key_padding_mask=None, need_weights=True,
                                attn_mask=None, average_attn_weights=True,
                                is_causal=False):
    if self_.batch_first:
        query, key, value = (t.transpose(0, 1) for t in (query, key, value))
    tgt_len, bsz, embed_dim = query.shape
    head_dim = embed_dim // self_.num_heads

    if self_._qkv_same_embed_dim and torch.equal(query, key) and torch.equal(key, value):
        q, k, v = F.linear(query, self_.in_proj_weight, self_.in_proj_bias).chunk(3, dim=-1)
    else:
        # cross-attention path: three separate projections
        w_q, w_k, w_v = self_.in_proj_weight.chunk(3)
        b_q, b_k, b_v = (self_.in_proj_bias.chunk(3)
                         if self_.in_proj_bias is not None else (None, None, None))
        q = F.linear(query, w_q, b_q)
        k = F.linear(key,   w_k, b_k)
        v = F.linear(value, w_v, b_v)

    q = q.contiguous().view(tgt_len, bsz * self_.num_heads, head_dim).transpose(0, 1)
    k = k.contiguous().view(-1,      bsz * self_.num_heads, head_dim).transpose(0, 1)
    v = v.contiguous().view(-1,      bsz * self_.num_heads, head_dim).transpose(0, 1)

    attn = F.softmax(torch.bmm(q * head_dim ** -0.5, k.transpose(1, 2)), dim=-1)
    out  = torch.bmm(attn, v).transpose(0, 1).contiguous().view(tgt_len, bsz, embed_dim)
    return self_.out_proj(out), None

for m in model.modules():
    if isinstance(m, nn.MultiheadAttention):
        m.forward = types.MethodType(_onnx_friendly_mha_forward, m)

Parity vs the fused kernel: 1 × 10⁻⁶ max diff. Safe.

The export call

Short answer: Legacy torch.onnx.export at opset 17, dynamo=False. dynamo_export dies on the patches anyway; legacy works.

from demucs.pretrained import get_model

bag = get_model("htdemucs_ft")
drums_model = bag.models[0].eval().cpu()
# apply all 4 patches above to drums_model

n = int(float(drums_model.segment) * int(bag.samplerate))   # 343980
dummy = torch.randn(1, 2, n, dtype=torch.float32)

torch.onnx.export(
    drums_model,
    dummy,
    "htdemucs_ft_drums.onnx",
    input_names=["mix"],
    output_names=["stems"],
    opset_version=17,
    dynamo=False,
    do_constant_folding=True,
)

316 MB per specialist. ~6.5 s export time on CPU. Passes onnx.checker.check_model. 24,765 nodes.

Repeat for bag.models[1] (bass), bag.models[2] (other), bag.models[3] (vocals). All four use the same architecture and patches.

Parity verification — the only acceptance test that matters

Short answer: Run the original PyTorch model and the ONNX model on the same fixed input, compute .abs().max(). Should be < 1e-3.

import numpy as np
import onnxruntime as ort

x = np.random.randn(1, 2, 343980).astype("float32")
sess = ort.InferenceSession("htdemucs_ft_drums.onnx",
                            providers=["CPUExecutionProvider"])
onnx_out = sess.run(["stems"], {"mix": x})[0]

torch_out = drums_model(torch.from_numpy(x)).detach().numpy()
print("max abs diff:", np.abs(onnx_out - torch_out).max())   # 1.63e-4

Per stem, against the original PyTorch htdemucs_ft at fp32:

Stem max abs diff
drums 0.000163
bass 0.000011
other 0.000739
vocals 0.000008

All comfortably under the 1e-3 tolerance that fp32 reordering normally explains. SDR scores measured against MUSDB18-HQ are unchanged — if you want to verify yourself, query the 800-row leaderboard from pandas without re-running the benchmark.

Inference with zero PyTorch (pure numpy + onnxruntime)

Short answer: Load the four ONNX files, do overlap-add chunking, sum the per-model outputs.

import numpy as np
import onnxruntime as ort
import soundfile as sf

SOURCES = ["drums", "bass", "other", "vocals"]
CHUNK = 343980             # 7.8 s * 44100 Hz
HOP   = CHUNK // 4

sessions = {
    s: ort.InferenceSession(f"htdemucs_ft_{s}.onnx",
                            providers=["CPUExecutionProvider"])
    for s in SOURCES
}

def separate(mix, sr=44100):
    pad = CHUNK - (mix.shape[-1] % CHUNK)
    mix_p = np.pad(mix, ((0, 0), (0, pad)), mode="constant")
    out = {s: np.zeros_like(mix_p) for s in SOURCES}
    weight = np.zeros(mix_p.shape[-1])
    w = np.hanning(CHUNK).astype("float32")

    for start in range(0, mix_p.shape[-1] - CHUNK + 1, HOP):
        chunk = mix_p[:, start:start+CHUNK].astype("float32")[None]
        for s in SOURCES:
            y = sessions[s].run(["stems"], {"mix": chunk})[0][0]
            target_row = SOURCES.index(s)
            out[s][:, start:start+CHUNK] += y[target_row] * w
        weight[start:start+CHUNK] += w

    for s in SOURCES:
        out[s] = (out[s] / np.maximum(weight, 1e-8))[:, :mix.shape[-1]]
    return out

mix, sr = sf.read("song.wav", dtype="float32", always_2d=True)
stems = separate(mix.T, sr)
for s in SOURCES:
    sf.write(f"{s}.wav", stems[s].T, sr)

Zero PyTorch. Zero MLX. Runs anywhere onnxruntime runs — iOS via onnxruntime-objc, Android via onnxruntime-android, browsers via onnxruntime-web.

Performance numbers

Short answer: ONNX Runtime CPU EP is 1.31× faster than PyTorch CPU. A single-specialist ONNX is ~5.7× faster than the full bag.

Apple M4 Pro, 3-minute song:

Backend Latency Notes
ONNX CPU EP — single specialist ~22 s Use this for vocal removers / drum extractors
ONNX CPU EP — full 4-stem bag ~88 s All stems
PyTorch CPU — full bag ~125 s Baseline
PyTorch MPS — full bag ~47 s Apple GPU
ONNX CUDA — NVIDIA L4 (extrapolated) ~6 s Server-side deployment

The single-specialist trick works because the htdemucs_ft bag is one-hot:

# the bag's per-model weight matrix
weights = [[1, 0, 0, 0],   # drums  = sub-model 0's drums output
           [0, 1, 0, 0],   # bass   = sub-model 1's bass output
           [0, 0, 1, 0],   # other  = sub-model 2's other output
           [0, 0, 0, 1]]   # vocals = sub-model 3's vocals output

Sub-model 3’s vocals output is the bag’s vocals output, bit-exact. If you’re building a vocal remover, ship sub-model 3 alone (~316 MB ONNX, ~75 MB quantized) instead of the full ~1.26 GB bag at identical per-stem quality.

Wrapping Up

The five new ONNX repos — all MIT-licensed, all parity-verified:

If you’d rather skip the deployment plumbing entirely, the StemSplit hosted vocal remover runs the exact same htdemucs_ft weights with credits, queueing, and a dashboard — same model, just hosted. The full long-form writeup on the StemSplit blog has the iOS/Swift, Android/Kotlin, and onnxruntime-web code samples as well.

Open an issue on any of the repos if you find a stem where your parity diff exceeds 1e-3 — would love to hear about it.

🤖 The Hacker in the Machine: Using AI Agents to Build Interactive Security Games

This is a submission for the Hermes Agent Challenge

Hey everyone! 👋 Hima Kartikeya here.

If you’ve seen my recent posts, you know I just finished my Class 10 ICSE board exams and I’m currently getting ready to start my polytechnic diploma journey. My ultimate dream is a career in Cyber Security, but on the side, I’m a small-scale indie game developer who loves storytelling and world-building.

When I saw the Hermes Agent Challenge, my brain didn’t just think about standard business automation or data spreadsheets. I immediately wondered: How can we use autonomous AI Agents to revolutionize the way we learn cyber security through interactive gaming?

When most people think of AI, they think of chat interfaces waiting for a prompt. But AI Agents are a completely different beast—they can think ahead, adapt to changes, use external tools, and execute a multi-step plan entirely on their own. Here is my concept for how indie creators can use agent frameworks to build the ultimate Cyber Security RPG.

🎮 The Core Concept: An Autonomous Virtual Adversary

Imagine a text-based terminal game where you play as a junior system administrator defending a simulated corporate network. Instead of fighting a static, pre-scripted game boss with predictable patterns, your opponent is a live AI Adversary Agent powered by Hermes.

The setup is simple but dynamic. The agent is given a strict goal: “Find a vulnerability in the player’s network configuration and exfiltrate the secret data flag.”

The game loop unfolds in real-time:

  1. The Agent Probes: The AI agent reads the current state of the game network (represented as environment variables or system logs).
  2. The Agent Acts: It dynamically decides to execute a virtual “port scan” or try to exploit a weak variable handler.
  3. The Player Reacts: The player sees the incoming connection logs and has to quickly write Python fixes, patch vulnerable loops, or close ports to stop the intrusion.

Because the agent can pivot and alter its strategy based on the specific defensive choices the player makes, no two playthroughs are ever identical!

🧠 Why Agent Reasoning Fits Perfectly with Game Logic

What makes a framework like Hermes brilliant for this kind of game design is its native capability for complex Reasoning, Planning, and Tool Execution (often called the ReAct framework).

Instead of a developer hardcoding millions of lines of complex “if/else” logic to simulate a smart hacker, the agent handles the decision-making loop naturally:

  • Observation: The agent checks the game state (“The player left a loop configuration unprotected on Port 80”).
  • Thought: It processes the logic (“An open port with unvalidated input means I can try to trigger a buffer overflow or a local Denial of Service”).
  • Action: It calls a specific virtual terminal tool within the game environment to execute the exploit.

For a student developer like me, this completely changes the scope of what is possible. It democratizes game AI, letting indie creators focus entirely on crafting deep narratives, realistic environments, and great educational security lessons.

💡 Final Thoughts: The Ultimate Classroom

Stepping into my diploma studies soon, I know that reading static security textbooks can sometimes get a bit dry. But when you transform that textbook into a living, breathing digital opponent—an autonomous agent actively trying to outsmart your code—learning secure programming habits becomes an addictive, competitive experience.

Tools like Hermes prove that open agentic systems aren’t just for automating corporate tasks. They are powerful platforms for building next-generation educational tools that can help train the next wave of security defenders.

Over to the Community:
To the senior developers, game designers, and AI engineers out there: If you were integrating an autonomous AI agent into a video game today, would you design them to be a clever ally helping the player guide the narrative, or an unpredictable adversary trying to beat them? Let’s brainstorm in the comments! 🚀👇

Savings Plan Amortized Cost in AWS Cost Explorer: What It Is and How to Use It

Savings Plan amortized cost converts a front-loaded or recurring Savings Plan fee into a daily rate you can actually reason about. The default unblended view shows what AWS billed your account each day so a 1-year All Upfront Savings Plan appears as a single large charge on Day 1, then silence for 364 days. Accurate for cash flow. Operationally useless for tracking commitment ROI, allocating costs to teams, or detecting underutilization.

Here’s what amortized cost is technically, how it differs from unblended and blended, and critically how to use it to catch unused commitment before it compounds.

What Is Savings Plan Amortized Cost?

Amortized cost is a cost dataset in AWS Cost Explorer and the Cost and Usage Report (CUR) that spreads Savings Plan fees uniformly across each day of the commitment term. It’s an accrual accounting view of your cloud spend.

The formula AWS applies:

Daily Amortized Fee = (Total Plan Fee) ÷ (Total Days in Term)

For a 1-year No Upfront Compute Savings Plan at $10/hour ($87,600/year):

  • Daily amortized fee: $87,600 ÷ 365 = $240/day regardless of whether EC2 usage consumed 100% or 60% of coverage that day

For a 1-year All Upfront plan at $80,000:

  • Unblended view: $80,000 on Day 1, $0 for Days 2–365

  • Amortized view: $219.18/day every day of the term

The effective cost per covered compute-hour in the amortized view is the true discounted rate, the number finance should use for chargebacks, and the number you should monitor to verify your Savings Plan is performing as expected.

The key operational difference:

An unblended view tells you what happened to your AWS bill. An amortized view tells you whether your Savings Plan is working.

If usage drops 40% in Month 6 on an All Upfront plan, the unblended view gives you no signal the $80,000 is already spent. In the amortized view, Month 6 shows unused commitment cost as a daily line item. That’s the waste signal.

If you want to understand how On-Demand, Reserved, and Spot pricing compares before committing, we covered the full breakdown here On-Demand vs Reserved vs Spot Instances: The Complete AWS Pricing Guide

How to View Amortized Cost in Cost Explorer

Prerequisites:

  • Cost Explorer enabled (Billing → Cost Explorer → Enable)
  • At least one active Savings Plan
  • Permissions: ce:GetCostAndUsage, ce:DescribeSavingsPlans

Step 1: AWS Console → Billing and Cost Management → Cost Explorer

Step 2: Set a date range covering your Savings Plan’s active period. 30 days minimum for utilization analysis.

Step 3: In the right-hand panel, open Advanced Options. The cost type defaults to Unblended.

Step 4: Select Amortized costs. If you have upfront Savings Plans, the previously lumpy daily cost line will flatten into a consistent daily rate.

Step 5: Group by dimension to see coverage distribution:

  • Group by Service → EC2 vs. Lambda vs. Fargate coverage
  • Group by Linked Account → which accounts received commitment coverage
  • Filter: Purchase Option = Savings Plans → isolate commitment cost from on-demand

Step 6: In the amortized view, AWS surfaces two Savings Plan line items:

  • Savings Plans recurring fee: daily amortized portion of the plan fee
  • Savings Plans upfront fee: if All/Partial Upfront, also shown as a daily amortized value

When usage fully covers the commitment, these appear as negative offsets against on-demand charges, netting to the discounted rate. When usage doesn’t cover the commitment, the unused portion appears as a positive cost with no offsetting savings. That’s your waste.

What Unused Commitment Actually Looks Like

Assume a Compute Savings Plan committing to $5/hour ($43,800/year, 1-year No Upfront).

Daily Usage vs. Commitment Outcome
100% Full $5/hour applies, zero unused commitment
70% $3.50/hour applied, $1.50/hour unused = $36/day wasted

50% $2.50/hour unused, $60/day = $1,800/month for nothing

None of this is visible as a daily signal in the unblended view. In the amortized view, unused commitment appears as a real-time daily cost line.

At scale: A 30% underutilized Compute Savings Plan on a $500,000/year commitment generates $150,000/year in unused commitment spend that produces zero AWS resources. At $6–12K/day in potential waste for larger environments, a single underutilization event undetected for 30 days is a $180K–$360K problem.

If you’re thinking through how to size and time your commitment purchases, we broke down the strategy here AWS Savings Plan Buying Strategy: Layering, Timing & Right-Sizing Commitment

Amortized Cost Across Consolidated Billing Accounts

In an AWS Organization, Savings Plans purchased in a management account can apply across all linked accounts. The amortized view is the only Cost Explorer dataset that properly attributes this coverage.

AWS applies the Savings Plan to usage across linked accounts (roughly by account ID, then by service). Each linked account’s amortized cost reflects the discounted rate for covered usage, regardless of which account owns the plan. Unused commitment is allocated back to the plan-owning account.

Why this matters for chargebacks: The unblended view shows the purchasing account bearing the full Savings Plan cost. The amortized view distributes that cost to accounts that actually consumed coverage. For any accurate internal cost allocation, you must use amortized cost unblended will systematically overcharge the management account and undercharge linked accounts.

Sample Athena query to surface unused commitment by day:

SELECT
line_item_usage_start_date AS usage_date,
savings_plan_savings_plan_a_r_n AS plan_arn,
SUM(savings_plan_unused_commitment) AS daily_unused_commitment,
SUM(savings_plan_used_commitment) AS daily_used_commitment,
ROUND(
SUM(savings_plan_unused_commitment) /
NULLIF(SUM(savings_plan_used_commitment + savings_plan_unused_commitment), 0) * 100, 2
) AS unused_pct
FROM your_cur_table
WHERE line_item_line_item_type = ‘SavingsPlanNegation’
OR line_item_line_item_type = ‘SavingsPlanCoveredUsage’
OR line_item_line_item_type = ‘SavingsPlanRecurringFee’
OR line_item_line_item_type = ‘SavingsPlanUpfrontFee’
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC;

Verify CUR field names against your specific CUR configuration field availability depends on CUR version and configuration.

The most common CUR mistake: summing lineItem/UnblendedCost and savingsPlan/SavingsPlanEffectiveCost in the same aggregation. That’s double-counting. Choose one methodology and apply it consistently.

What the Amortized View Can’t Tell You

Amortized cost is necessary for commitment analysis but not sufficient for commitment management.

Latency: Cost Explorer data has a 24–72 hour refresh latency. Savings Plans utilization can lag by up to 3 days. At $6–12K/day in potential waste, a 72-hour refresh cycle means a utilization problem can compound to $18K–$36K before it appears in your dashboard.

No forward-looking signal: The amortized view shows historical commitment performance. It can’t tell you whether your current usage trajectory will overshoot or undershoot your commitment for the rest of the month.

No automated action: Cost Explorer shows you the problem of unused commitment as a daily cost line. Fixing it (adjusting commitment size, redistributing coverage, or recovering unused commitment value) requires a separate workflow.

Closing the Gap Between Signal and Action

Most teams review Savings Plans utilization monthly or quarterly. AWS Cost Explorer’s recommendation engine refreshes every 72+ hours. By the time a utilization drop appears, gets reviewed in a weekly FinOps meeting, and reaches a decision, 2–4 weeks of unused commitment may have accumulated.

Usage.ai refreshes commitment data every 24 hours. On a $500K/year Savings Plan with 20% underutilization, you’re burning ~$274/day in unused commitment and miss it for 3 days due to stale tooling, that’s $822 gone before anyone sees the signal.

The other differentiator: most vendors in this space issue credits when commitment goes unused. Credits apply against future AWS invoices. Usage.ai returns cash actual money back, not a discount on next month’s bill. Unused commitment stops being a sunk cost and becomes a recoverable one.

Setup operates at the billing layer with only no infrastructure changes, no access to production resources. Commitments are sized to actual baseline usage rather than peak, which directly reduces the unused commitment visible in your amortized cost view.

For FinOps teams managing a consolidated AWS Organization, the amortized view of unused Savings Plan commitment is the first diagnostic signal. The question is how fast you act on it.

How does your team currently monitor Savings Plan utilization Cost Explorer, CUR queries, a third-party tool, or something else?

For a deeper technical breakdown and additional insights, read the full article here → Understanding Savings Plan Amortized Cost in AWS Cost Explorer

How to Tailor Your Resume to a Job Description in 5 Minutes (A Method That Actually Works)

If you’ve ever heard “tailor your resume for every application” and immediately thought that sounds exhausting, you’re not alone. Most people skip tailoring entirely — or spend hours rewriting a document from scratch — because no one explains what targeted tailoring actually looks like.

Here’s the reality: for most applications, strong resume tailoring is a top-half edit, not a full rewrite. If your base resume is solid, you can tailor it to a specific job description in roughly five minutes. This guide walks through the method step by step.

The Core Insight: Recruiters Compare, They Don’t Read in a Vacuum

When a recruiter opens your resume, they’re not assessing it as a standalone document. They’re comparing it against the specific job posting they wrote. ATS systems work the same way at scale — they’re built to identify how well a resume matches a particular set of requirements.

A well-written generic resume can still underperform because it doesn’t reflect the vocabulary or priorities of the role. Tailoring fixes that without requiring a full rewrite.

The 5-Minute Method

Minute 1: Highlight the Repeated Keywords in the Job Description

Start with repetition. Pull up the job posting and mark everything that appears more than once:

  • Job title variations
  • Tools and technical skills
  • Core responsibilities
  • Outcome words (optimize, analyze, lead, scale, automate)
  • Domain terms (fintech, B2B SaaS, healthcare, compliance)

Don’t try to match every word. Focus on the 8–12 signals that actually define the role. For a software engineer posting, that might be: TypeScript, React, REST APIs, performance optimization, cross-functional collaboration.

Minute 2: Pick the 3 Requirements You Must Match

Not everything in a job description carries equal weight. Your goal is identifying the three requirements that would make a recruiter say “yes, this person looks plausible.”

Usually those are:

  1. The core function of the role
  2. The main tools or stack
  3. The business outcome or scope

For a product manager role, if the posting repeats customer research, roadmap prioritization, and stakeholder communication — those three drive your edits, even if the JD lists 15 other things.

Minute 3: Rewrite the Summary and Skills Section First

The top of your resume gets scanned first, which makes it the fastest win.

Before: “Software engineer with experience building web applications and backend systems.”

After: “Software engineer with experience building React and TypeScript applications, shipping REST API integrations, and improving performance for internal and customer-facing tools.”

The second version presents the same real experience in the language the hiring team is already using. Then tighten the skills section so your most relevant tools are visible without scrolling.

Minute 4: Rewrite Only the 4–6 Bullets That Carry the Most Weight

This is where most of the tailoring value lives. Target:

  • Your most recent role
  • Your most relevant project
  • One bullet that shows results
  • One bullet that shows ownership or cross-functional work

Before: “Worked on APIs and frontend features for internal systems.”

After: “Built React workflows and Node.js REST APIs for internal operations tools, reducing manual processing time by 28% and improving cross-team reporting reliability.”

The second version names the tools, provides a concrete outcome, and mirrors the way real job descriptions are written. That’s the pattern.

Minute 5: Remove Bullets That Dilute the Match

Tailoring isn’t only about adding keywords — it’s also about cutting noise.

Remove or shorten bullets that:

  • Don’t support the target role
  • Repeat the same kind of work
  • Use generic language without outcomes
  • Make your resume look unfocused

Most resumes actually improve when 10–20% of the content is removed. Less noise means the relevant material stands out more clearly.

The 5 Mistakes That Make Resume Tailoring Ineffective

1. Rewriting the whole resume from scratch. You don’t need a new document for every application. You need a focused edit.

2. Copy-pasting the job description. Mirroring language is good. Pasting the JD directly into your resume is obvious and unconvincing.

3. Ignoring the top third of the page. If your summary, title, and skills block still look generic, the whole resume will feel generic — even if the bullets are great.

4. Only changing the skills section. Keyword overlap helps with ATS. Bullets are what make the resume believable to a human recruiter.

5. Never checking the final version against the posting. Tailoring should end with a comparison, not a guess. Run a final ATS check before submitting.

A Quick Checklist Before You Submit

  • 8–12 key terms highlighted from the job description
  • 3 must-match requirements identified
  • Summary updated to reflect role language
  • Skills section reordered for relevance
  • 4–6 key bullets rewritten
  • Off-topic bullets removed or shortened
  • Final ATS comparison done

This takes about 5 minutes once you’ve done it a few times. The first run usually takes 10–15 minutes, and it gets faster every time.

One Tool Worth Using

If you want to move faster on the ATS comparison step, ManyOffer Resume Match lets you paste your resume and the job description side by side and immediately see which requirements, keywords, and phrasing are still missing.

Read the full article here

Been using ManyOffer to sharpen my own answers — if you want AI mock interviews with real LP feedback, they have a deal running through July worth checking out: Claim 1 free month here

Flutter vs React Native in 2026: I Built the Same App in Both

Flutter app and React Native app running side-by-side in iOS and Android simulators, both showing the same task list with identical data and a bottom navigation bar with three tabs

I spent three weekends building the same app twice. Not as an experiment — I had a real decision to make. We’re adding a mobile companion to one of our internal tools at work, a task management system that runs on Spring Boot microservices. My team has touched Dart before; I’m more comfortable with JavaScript. Neither of us had shipped serious mobile work in the last two years. The only honest way to pick a stack was to build something real in both frameworks and compare what I found.

The app I built isn’t a toy. It pulls tasks from a REST API, caches them locally with SQLite, fires scheduled push notifications when deadlines approach, and has smooth list animations on a bottom-nav layout with three tabs. Simple enough to build in a weekend. Complex enough to expose real differences between the two frameworks.

Here’s what I found — including a result that surprised me.

The App

Before comparing, let me be specific about what I built. Both versions:

  • Fetch tasks from a paginated REST endpoint with auth headers
  • Cache responses locally using SQLite
  • Support optimistic updates when marking a task complete
  • Schedule local push notifications 1 hour before each task’s due time
  • Use a bottom navigation bar with three tabs

This isn’t production code — it’s a controlled comparison. Both apps hit the same mock API running on my MacBook. Same UI design, same feature set, different frameworks.

Setup and Developer Experience

Flutter’s setup is more involved upfront. You run flutter doctor, chase down Android SDK versions, configure Xcode command-line tools. On a clean Mac it took me about 45 minutes to get a green build on both simulators. The error messages are usually specific enough to guide you, but it’s mechanical work.

React Native with Expo took 15 minutes. npx create-expo-app, pick a template, run npx expo start, scan the QR code. Done. The friction delta is real, especially if you’re onboarding a team that’s new to mobile development.

The catch: Expo’s managed workflow works brilliantly until you need a native module that isn’t in Expo’s SDK. Then you eject, and you’re roughly back to the same complexity as a bare React Native setup. For this test app, I stayed in managed Expo and hit no walls.

Flutter app and React Native app running side-by-side in iOS and Android simulators, both showing the same task list with identical data and a bottom navigation bar with three tabs

Writing UI Code

This is where the frameworks feel most different day-to-day.

Flutter uses widgets — everything is a widget, composable and explicit. Here’s the task list item:

class TaskTile extends StatelessWidget {
  final Task task;
  final VoidCallback onComplete;

  const TaskTile({super.key, required this.task, required this.onComplete});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(
        task.title,
        style: task.completed
            ? const TextStyle(decoration: TextDecoration.lineThrough)
            : null,
      ),
      subtitle: Text(task.dueAt.toLocal().toString().substring(0, 16)),
      trailing: task.completed
          ? const Icon(Icons.check_circle, color: Colors.green)
          : IconButton(
              icon: const Icon(Icons.radio_button_unchecked),
              onPressed: onComplete,
            ),
    );
  }
}

React Native with TypeScript:

type TaskTileProps = {
  task: Task;
  onComplete: () => void;
};

export function TaskTile({ task, onComplete }: TaskTileProps) {
  return (
    <TouchableOpacity
      style={styles.row}
      onPress={task.completed ? undefined : onComplete}
    >
      <View style={styles.info}>
        <Text style={[styles.title, task.completed && styles.completed]}>
          {task.title}
        </Text>
        <Text style={styles.due}>{formatDate(task.dueAt)}</Text>
      </View>
      {task.completed ? (
        <Ionicons name="checkmark-circle" size={22} color="#4CAF50" />
      ) : (
        <Ionicons name="radio-button-off" size={22} color="#999" />
      )}
    </TouchableOpacity>
  );
}

Both are readable. The Flutter version is more verbose but more structured — you’re always in an explicit tree of typed widgets. The React Native version feels immediately familiar if you write React for the web, which is either an advantage or a liability depending on your team’s background.

One thing I noticed: Flutter’s hot reload is faster and more reliable than React Native’s throughout a full day of development. Not dramatically — but consistently.

State Management

I used Riverpod in Flutter and Zustand in React Native. Both are lightweight and explicit. I deliberately avoided Provider or Redux for a project this size.

Flutter with Riverpod:

final tasksProvider = AsyncNotifierProvider<TasksNotifier, List<Task>>(
  TasksNotifier.new,
);

class TasksNotifier extends AsyncNotifier<List<Task>> {
  @override
  Future<List<Task>> build() => _fetchTasks();

  Future<void> completeTask(String id) async {
    final previous = state;
    state = AsyncData(
      state.value!
          .map((t) => t.id == id ? t.copyWith(completed: true) : t)
          .toList(),
    );
    try {
      await TasksApi.complete(id);
    } catch (_) {
      state = previous;
    }
  }
}

React Native with Zustand:

interface TaskStore {
  tasks: Task[];
  loading: boolean;
  fetchTasks: () => Promise<void>;
  completeTask: (id: string) => Promise<void>;
}

export const useTaskStore = create<TaskStore>((set, get) => ({
  tasks: [],
  loading: false,
  fetchTasks: async () => {
    set({ loading: true });
    const tasks = await TasksApi.fetchAll();
    set({ tasks, loading: false });
  },
  completeTask: async (id) => {
    const previous = get().tasks;
    set({
      tasks: previous.map((t) => (t.id === id ? { ...t, completed: true } : t)),
    });
    try {
      await TasksApi.complete(id);
    } catch {
      set({ tasks: previous });
    }
  },
}));

The patterns are nearly identical — optimistic update, rollback on failure. The ergonomics are a wash. If you already know one of these libraries, it takes an afternoon to feel comfortable with the other.

Performance

This is where the narrative shifted for me.

Flutter renders through its own engine. Impeller is now the default on both iOS and Android in 2026. It doesn’t use native UI components — it draws everything itself. This means frame-perfect consistency across platforms and animations that don’t depend on a JavaScript thread.

React Native’s new architecture — JSI plus Fabric, stable and enabled by default since RN 0.74 — eliminated the old async bridge. Thread communication is now synchronous. This closed a significant performance gap in 2024-2025.

Side-by-side architecture comparison — Flutter's Dart VM plus Impeller rendering pipeline on the left, React Native's JSI plus Fabric architecture on the right, showing how UI updates flow from app code to the screen in each case

In practice, for this app, I couldn’t reliably tell the difference on a modern device. Both hit 60fps on the list scroll. The gap appears when you push harder — complex custom animations, heavy computation, very long lists. Flutter still wins there, but you have to be building something demanding to care.

Build sizes: Flutter release APK was 21MB. React Native bare was 18MB. Flutter carries its engine everywhere.

Ecosystem and Third-Party Libraries

React Native benefits from the entire JavaScript ecosystem. Need a date picker, a chart library, a PDF generator? There’s an npm package. The question is always whether it has real native bindings or is a pure-JS fallback.

Flutter’s pub.dev is smaller but more curated. Packages tend to be higher quality and better maintained because the community is more focused. For common needs — HTTP clients, SQLite, push notifications, state management — the Flutter ecosystem is solid.

Where Flutter still lags: some SDKs ship their React Native version first and Flutter second, sometimes months later. Analytics tools, some payment gateways, specific third-party integrations. If your app depends on one of these, check the Flutter SDK availability before committing.

For push notifications specifically, both flutter_local_notifications and Expo Notifications worked cleanly in my test. No meaningful difference in API quality. Flutter’s setup requires touching native config files directly; Expo abstracts that away.

What I Shipped

I shipped the Flutter version. The decision came down to factors specific to my situation.

UI consistency — our app has custom list animations and a design language that needs to look identical on iOS and Android. Flutter’s renderer guaranteed that without platform divergence.

Existing Dart exposure — my team had briefly used Flutter in 2023. The ramp-up cost was lower than switching to a mobile-specific React/TypeScript setup everyone would need to learn from scratch.

No exotic SDK requirements — we don’t depend on any third-party SDK that would put us at risk of the “Flutter SDK coming soon” problem.

Desktop is on the roadmap — Flutter’s single codebase across mobile, desktop, and web matters for us. We already run a Spring Boot backend; adding a Flutter desktop client for internal ops is straightforward.

If our team had been JS-heavy, or if we’d needed to share components with a web frontend, React Native would have been the right call.

The Honest Summary

Flutter makes more sense when:

  • Pixel-perfect custom UI — you control the renderer entirely
  • Performance-heavy animations — no JS thread bottleneck
  • Targeting beyond mobile — Flutter Desktop and Web are real options in 2026
  • Team is willing to learn Dart — the investment is smaller than it looks

React Native makes more sense when:

  • JS-heavy team — zero ramp-up if your team already knows React
  • Sharing logic with web — React Native Web and shared business logic are mature
  • Expo speed for MVPs — managed Expo is genuinely the fastest path to a working app
  • Broad SDK availability — some third-party SDKs still prioritize RN bindings

Neither framework is a mistake in 2026. The “Flutter vs React Native” war of 2019-2022 produced a lot of hot takes that aged poorly. Both ship real production apps. Both have active communities. Both have converged enough that your team’s background matters more than the framework’s raw capabilities.

The one thing I’d push back on: don’t pick React Native just because everyone on your team knows JavaScript. Mobile development has enough platform-specific quirks — permission flows, background tasks, notification entitlements, keychain storage — that the framework choice is secondary to understanding how iOS and Android actually work. That learning is required either way.

What are you running in mobile production in 2026? And if you switched frameworks at some point — Flutter to React Native or the reverse — what finally pushed you over the line?

Originally published on Medium.

JWT vs Session Tokens in Spring Boot: A Senior Dev’s Decision Guide

Side-by-side flow comparison — Session flow: client cookie to server to Redis lookup to SecurityContext; JWT flow: client Bearer token to server to local signature verify to SecurityContext. Annotate the revocation point on sessions (delete from Redis) and the revocation gap on JWT (no early invalidation without blocklist)

Three years ago I gave the same answer every time someone asked me about authentication in Spring Boot: “use JWT, it’s stateless, it scales.” I was half right and half wrong, and it took inheriting two production codebases — one broken in a very specific way — to understand which half was which.

This is not a tutorial on how to implement either one. It’s the decision guide I wish I’d had before I started recommending JWT by default.

What tutorials actually teach you

Most Spring Boot security tutorials walk you through JWT because it makes for a cleaner demo. You add a filter, validate a signature, set the SecurityContext, done. No database calls, no shared state, stateless by construction. It feels architecturally clean.

What they rarely show: what happens when a user changes their password. Or gets their account suspended. Or logs out on one device and expects that to mean something on all devices. With a pure JWT setup and no blocklist, the answer to all three is “nothing happens until the token expires.”

Sessions, on the other hand, feel old-fashioned. “That doesn’t scale.” “You need sticky sessions.” Neither of those is true anymore, and I’ll show you why.

How sessions actually work in Spring Boot

Spring Session with Redis is three annotations and a dependency:

@EnableRedisHttpSession
@Configuration
public class SessionConfig {
    // Spring Session handles everything else
}
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

The client gets an opaque session ID (typically 32 hex characters) stored in an HttpOnly; Secure cookie. Every request sends the cookie, Spring looks up the session in Redis, deserializes it, and populates the SecurityContext. The session data lives in Redis, not in the token itself.

Revocation is sessionRepository.deleteById(sessionId). Instant, no exceptions.

Horizontal scaling works out of the box — every instance connects to the same Redis. No sticky sessions needed. This is not 2009 anymore.

Side-by-side flow comparison — Session flow: client cookie to server to Redis lookup to SecurityContext; JWT flow: client Bearer token to server to local signature verify to SecurityContext. Annotate the revocation point on sessions (delete from Redis) and the revocation gap on JWT (no early invalidation without blocklist)

JWT: what you actually get

A JWT is a base64-encoded JSON payload (claims) signed with a secret or private key. The server does not store it. Verification happens locally by checking the signature — no database call, no network hop.

This matters in two specific situations:

Microservices that verify tokens independently. If you have five services and each needs to know who the caller is, sessions require every service to call a shared store or a central auth service. JWT lets each service verify the token locally with just the public key or shared secret.

Third-party and mobile clients. Browsers handle cookies automatically. Native apps and third-party API clients do not. JWT in the Authorization header works everywhere without cookie configuration.

Outside these two cases, you are paying the JWT costs without getting the JWT benefits.

The costs are real:

  • No revocation without a blocklist. A 15-minute access token cannot be invalidated early. A stolen token is valid until expiry. If you add a Redis blocklist to check on every request, you have just re-added the database call you were trying to avoid.
  • Token size. A session cookie is 32 bytes. A JWT with a handful of claims is 300–600 bytes in every request header, forever. In high-frequency internal APIs this adds up.
  • Implementation surface. JWT has a long history of security bugs: alg: none attacks, weak HMAC secrets, missing expiry validation, incorrect audience checks. Spring Security handles most of this correctly, but the complexity budget is higher than sessions.

The real decision framework

Stop asking “which is better?” and ask “what do I actually need?”

Use sessions when:

  • You control the frontend — a browser-based app using your own backend
  • You need immediate revocation: logout means logout, password change means all sessions die
  • You are building a monolith or a small service that owns its own auth
  • You are already running Redis for caching or queuing

Use JWT when:

  • Multiple independent services need to verify identity without calling a central store
  • You have non-browser clients — mobile apps, CLI tools, third-party integrations — that cannot easily handle cookies
  • You need federated identity: a token issued by an external IdP (Auth0, Keycloak, Cognito) that your service validates

Do not use JWT because:

  • “It’s stateless and scales” — sessions on Redis scale just as well across any number of instances
  • “Everyone uses it” — cargo-culting security decisions is how you end up with 7-day non-revocable tokens in production

The hybrid setup nobody talks about

Most production systems I’ve seen that do this well use a combination: sessions for the browser frontend, JWT for service-to-service calls and mobile clients.

Spring Security supports this cleanly with multiple SecurityFilterChain beans:

@Bean
@Order(2)
public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/v1/**")
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(AbstractHttpConfigurer::disable)
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    return http.build();
}

@Bean
@Order(1)
public SecurityFilterChain sessionFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/web/**")
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
        .formLogin(Customizer.withDefaults())
        .logout(logout -> logout.logoutSuccessUrl("/web/login?logout"))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/web/login").permitAll()
            .anyRequest().authenticated());
    return http.build();
}

Two chains, different matchers, different strategies. The browser app gets sessions and full revocation. API and service calls get JWT. Spring applies them in @Order sequence — the first matching chain wins.

What the performance difference actually looks like

Sessions add one Redis round-trip per request — typically 0.5–2ms on a well-configured local Redis, 2–5ms if Redis is in a separate availability zone. For a request that already takes 50–200ms to process, that is noise.

JWT validation is in-memory: parse the base64, verify the HMAC signature, check expiry. Sub-millisecond. If you are building an API that needs to handle thousands of requests per second with microsecond budgets, this difference matters. If you are building a standard web application or a business API, it does not.

The token size difference matters more than you would expect in aggregate. A session cookie is SESSION=<32-hex-chars> — about 50 bytes in the Cookie header. A JWT is Authorization: Bearer <base64> — typically 400–700 bytes in the Authorization header, on every single request. In an application with 10,000 active users making 20 requests per hour, that is roughly 70MB/hour in header overhead alone versus 5MB with sessions. On internal microservice APIs with high call frequency, this adds up to real cost.

Neither of these is a reason to choose one over the other by itself. They are factors to weigh against the architectural fit, not arguments that make the decision for you.

Security mistakes that are easy to make with JWT

Spring Security protects you from many JWT pitfalls if you use it correctly, but I have seen all of these in production codebases:

Symmetric secret too short. HS256 requires at least 256 bits (32 bytes). A short secret is brute-forceable. Generate it with openssl rand -base64 32 and store it in your secrets manager, not in application.yml.

No audience or issuer validation. If you have multiple services accepting the same JWT, a token issued for service A can be replayed against service B unless you validate the aud and iss claims. Spring Security’s JwtDecoder supports this with .claimValidator("aud", ...).

Logging the token. Access logs, debug statements, error traces. A JWT is a credential. Treat it like a password in your logging configuration.

Using RS256 in a monolith. RS256 (asymmetric) makes sense when multiple services need to verify tokens issued by a single auth service. In a monolith where only one service issues and verifies tokens, RS256 adds key management complexity with no security benefit. HS256 is the right default.

The mistake I see most often

Teams start with JWT because a tutorial recommended it. Six months later, they need to implement “logout from all devices” or “force re-authentication after a password change.” At that point they bolt on a Redis blocklist to invalidate tokens before expiry — which means every JWT validation now hits Redis on every request. They have kept all the JWT complexity and added the session store on top.

I have done this. The resulting code is harder to reason about than either pure sessions or pure JWT would have been.

If you need revocation, use sessions. If you genuinely need stateless cross-service verification, use JWT and design around the revocation limitation deliberately — 15-minute access tokens, refresh token rotation, a clear session invalidation policy documented before you ship — not as an afterthought six months later.

application.yml showing spring.session.store-type=redis, spring.session.timeout=30m, server.servlet.session.cookie.http-only=true, server.servlet.session.cookie.secure=true — the minimal Spring Session Redis config needed to replace an overly complex JWT setup

The boring answer is often the right one. Spring Session with Redis has been solving this problem correctly since 2015. JWT solves a specific distributed systems problem. Know which one you actually have before you choose.

What does your current setup use, and was it a deliberate choice or did it come from a tutorial?

Originally published on Medium.

What if Everybody Were Suddenly… Better?

Mario about to grab a mushroom and grow

We (and by we I often mean ‘me’ 🙃) spend LOTS of time talking about how to improve our work. LinkedIn-fluencers (even the crappy AI-bot ones) talk about optimizing and improving and making things better all the time. It’s great to have goals like this: self-improvement is admirable, and I fully believe that the best people at any given task (software engineering, plumbing, music, you name it) are the ones who are always working to improve what they do.

To absolutely no one’s surprise, though, we don’t often see the organizational improvements we advocate for (or maybe some of us are surprised by this? idk, let me know in the comments!). Working with people is a messy business: sometimes they’re politically or financially incentivized to follow “The Old Way”, and no amount of logic will change their minds. Sometimes we do a crappy job of stating our case and they don’t understand the change we want to make. Sometimes “this is suboptimal, but I don’t have to put in additional effort” wins out over a better idea that requires a little sweat equity. We might give the needle a small nudge, but often we put in a lot of effort and glean relatively little progress from it. It can be maddening at times.

Fever Dream Fantasy: The Thought Experiment

There’s an amazing dude named John Cutler over at DotWork who, at the end of 2025, proposed a really intriguing thought experiment. As I’m drafting this almost two full months later, I’ve had some time to think about what he suggested for a while now…

Here’s a link to his original post, but if for some reason LinkedIn decides to eat that, I’ve copied the text below for posterity:

Here’s a fun little thought exercise. Suddenly everyone in your company is 30% more capable, more competent, more skilled, more experienced.

What would actually happen?

Mario about to grab a mushroom and grow

That kind of outcome feels too good to be true. (Which is probably why it’s a thought experiment!) I mean, imagine showing up on Monday morning and EVERYONE IN THE WHOLE COMPANY is leveled up like that! Wouldn’t it be great? We’d be SO profitable and SO efficient and we’d absolutely CRUSH it every day, wouldn’t we?

Padme looking hopeful
…wouldn’t we?

Padme looking worried

The Answer Lies in What DIDN’T Improve

Our 30% improvements were in:

  • Capability
  • Competency
  • Skill
  • Experience

But think about what isn’t in that list:

  • Incentives
  • Priorities
  • Decision-making latency
  • Trust levels
  • Clarity of ownership
  • How work flows
  • How conflict is handled
  • How failure is punished

Oh, sure, you’ve increased the Talent. But you’ve left them in a broken system. Think about it like this: your engineer is 30% better at shipping things, but if you still have problems with prioritization, they’re shipping the wrong thing 30% faster.

Did you really gain anything by that?

What if your approval process takes weeks, or even months? Making the engineers work faster doesn’t really help. (BTW: This is a concept codified forty years ago, called the Theory of Constraints. Nothing new!)

What if your team struggles with psychological safety? Even if you increase their skill overnight… they’re still not going to contribute more when it isn’t safe to speak up.

Leadership Always Falls for The “Get Better People” Trap

Listen to senior leadership discussions, and you’ll realize that many (most? maybe all?) have fallen for it:

  • “I only want A players on my team.”
  • “We need more Senior/Staff/Principal Engineers.”
  • “We need higher standards in the organization.”
  • “We’re going to outsource {some competency} because it will be more efficient than investing in our teams developing the skill.”

It’s tantalizing to think this way:

“Oh, well Bob isn’t working out for us – he’s competent, but he’s just too slow. Let’s cut him and hire one of those 10x guys I keep hearing about.”

This kind of thinking feels natural to us. If you grew up playing TECMO Bowl, you remember Bo Jackson. He was that 10x Running Back that you and your siblings always fought over – because whoever had him was going to win.

Touchdown Bo Jackson

On the surface, managers and directors think like this because it feels like meritocracy. “We gave Bob a chance, he couldn’t hack it here.” It’s tangible – something they can see and do, so they feel like they’re hands-on “managing” their team. But I think they’re actively destroying their team by doing this.

Wanna know why?

  • The revolving door “Bob couldn’t hack it” approach is easier than doing the culture work that fixes what actually kept Bob from succeeding.
  • Bob gets the blame that rightly belongs to the leadership system… and every other engineer sees that management will cut people instead of admitting a fault.

Here are some “translations” you might want to consider:

Easy Story Hard Reality
We need A-players. Our environment suppresses B+ players instead of helping them grow.
We need more Seniors. Our decision process is chaotic.
We need higher hiring standards. We don’t protect focus, or prioritize well, or communicate well.

For Senior Leadership, it can be psychologically safer to blame their people than to honestly assess the management system.

Environment Shapes Your Team’s Productivity

If you’ve ever read anything on the Adventures of Blink, you know I firmly believe that good Developer Experience is the foundation of your engineering team’s ability to increase revenue… but judging from what I observe around me, there aren’t many in senior leadership roles who’ve made that connection.

Let’s consider it like a simple equation:

Impact = Talent × Environment

So if everyone were 30% more talented, 1.3x talent seems pretty exciting, doesn’t it?

Set your environment to 0.5, and even your extra-talented team underdelivers.

Now let’s look at it a different way: If you provide a great environment, even your average talent looks incredible!

Why we can trust the equation

Blink, you just made that formula up. Well… you’re right about that… but I think it works anyway! Here’s why:

Clear priorities → Fewer wasted cycles

Strong trust → Faster decisions

Tight feedback loops → Rapid improvement

Psychological safety → Better idea-sharing

Good DevEx → Skill converts to output

Providing a safe, empowering, collaborative environment clearly acts as a force multiplier across all these dimensions.

The Right Questions

The easy question for leadership is “How do we get better people?” It’s an alluring question because people in management fantasize that the answer is found in hiring practices, in clever interview questions, in asserting themselves as “high-performance leaders” who go and get things done.

But the right question to ask looks a little different:

What would need to change for our current people to operate at 130%?

That’s a tough pill to swallow because it reframes the responsibility.

In order to answer that question, you can start by looking for the answers to these:

Where do good ideas go to die?

You might actually have lots of great ideas coming from your staff… but they get strangled before they’re realized. Find out why that is, and fix it.

Where does energy leak?

I’ve watched a highly motivated team absolutely burn out because they spent as much time fighting the system as they did innovating.

Where does friction accumulate?

You didn’t wake up one morning and say “hey let’s implement a painful cumbersome process” (well, at least I hope you didn’t!). It’s been compounding in the shadows for a while. Ask the team where it is, and listen carefully. They’ll show you.

What behaviors are we quietly (and/or accidentally) rewarding?

I once knew a manager who obsessed over his team’s compliance scores. If a finding came out for his team, he insisted on dropping ALL planned work to deal with it.

Governance and Compliance learned that if they needed something from him, they could rattle the Findings saber and instantly move to the front of the line.

His team learned that their innovation wasn’t as important as their Compliance scores.

Think about how that affected everyone’s choices.

Not to put too fine a point on it, but…

If 30% more capable people wouldn’t radically transform your company, that’s not a talent problem; it’s a system problem.

The fantasy is that better people will fix the system.
The reality is that the system shapes what people can become.

Wrapping Up

I get it: managers want to manager. They see a quick path to “fixing the problems” and it tempts them with their own agency:

“You can fix this broken team, just get rid of the low performers and get some rockstars in there”.

I’ve felt like that before. And it’s hard to tell the difference between a B+ player who’s been hamstrung by a bad environment and a C- player that’s genuinely a bad fit. All I’m saying is that maybe we need that trigger-finger to be a little less itchy, and apply a little introspection first.