Author: khalil

  • Logging and observability best practices

    Logging and observability best practices

    You find out how good your observability is at the worst possible moment: when something is broken, customers are noticing, and you have no idea why. Everything I do here is aimed at that moment. The goal is to answer “what is happening and why” in minutes, not hours. Good observability is the difference between a calm incident and a frantic one.

    Log structured data, not sentences

    Human-readable log lines feel friendly until you need to search ten million of them. Then you are writing fragile regexes against prose. I log structured records, key-value or JSON, so logs are queryable like a database instead of grepped like a diary.

    // Not this:
    log.info("User " + userId + " failed login from " + ip)
    
    // This:
    log.info("login_failed", {
      user_id: userId,
      ip: ip,
      reason: "bad_password",
      attempt: 3
    })

    Now “show me all failed logins for this user in the last hour” is a filter, not an archaeology project. Pick consistent field names across services so the same concept has the same key everywhere, and a query written once works across the whole system.

    Use levels with discipline

    Log levels only help if they mean something consistent. When everything is logged at INFO, the level is noise. My rule of thumb:

    • ERROR is something broken that a human needs to look at. If it does not warrant attention, it is not an error.
    • WARN is unexpected but handled, the kind of thing worth watching for a pattern.
    • INFO is significant business events: an order placed, a job finished.
    • DEBUG is detail for local development, usually off in production.

    The test for ERROR is simple: if a page fired for every one, would you be angry? If yes, it is not really an error, and you have just trained yourself to ignore the level that is supposed to wake you up.

    Carry a request ID through everything

    In any system with more than one service, a single user action becomes a dozen log lines scattered across machines. Without a thread connecting them you are guessing. I generate a correlation ID at the edge and pass it through every downstream call and into every log line. Then one ID reconstructs the entire path of a request, which is the same reason I keep error shapes consistent in REST API design guidelines: when something else has to follow the trail, structure wins.

    Measure the three things that tell you about health

    Logs tell you about specific events. Metrics tell you about the system as a whole, and they are what your dashboards and alerts run on. For any service that handles requests I track rate, errors, and duration: how many requests, how many failed, and how long they took. Watching the latency distribution rather than the average matters, because the average hides the slow tail where real users suffer.

    • Track the 95th and 99th percentile latency, not just the mean.
    • Track error rate as a percentage so it is meaningful at any traffic level.
    • Track saturation, how full your resources are, so you see trouble before it becomes an outage.

    Alert on symptoms, not causes

    An alert should mean a human needs to act now. If it does not, it should be a dashboard, not a page. The fastest way to make on-call hate their life is alerts that fire constantly and mean nothing, because people learn to swipe them away and then miss the one that mattered. I alert on user-facing symptoms, like error rate crossing a threshold or latency blowing past its budget, rather than on internal causes like high CPU, which may be perfectly fine.

    One more thing that pays off: never log secrets, passwords, tokens, or full payment details. It is easy to leak them into logs by accident, and logs sprawl across systems that have weaker access controls than your database. The same constraint-driven care I described in database schema best practices belongs here too. Decide what is sensitive, then make sure it never reaches a log line in the first place.

  • Flutter state management with Provider, Riverpod, and Bloc

    Flutter state management with Provider, Riverpod, and Bloc

    State management is where Flutter projects either stay sane or rot. The framework ships with setState, which is fine for a single widget, but as soon as state needs to be shared across screens you need something better. I have shipped apps on Provider, Riverpod, and Bloc, and each one earns its place in different situations. Here is how I choose.

    Why setState stops being enough

    setState rebuilds the widget it lives in. That works until two screens need the same data, or until a deep child needs a value held near the root. You end up passing callbacks and values down through constructors, layer after layer, and the term for that misery is prop drilling. Every state library exists to solve this same problem of getting data to where it is needed without threading it through everything in between.

    Provider, the gentle start

    Provider is the official recommendation for a long time and still a reasonable default. It sits on top of inherited widgets and gives you a clean way to expose a value to the tree below it. A ChangeNotifier holds your state and calls notifyListeners when something changes, and widgets that listen rebuild. It is simple to reason about and easy to teach a new team member.

    import 'package:flutter/material.dart';
    
    class CartModel extends ChangeNotifier {
      final List<String> _items = [];
      List<String> get items => _items;
    
      void add(String item) {
        _items.add(item);
        notifyListeners();
      }
    }

    The weakness of Provider shows up at scale. It is tied to the widget tree, so testing logic in isolation takes effort, and it is easy to rebuild more of the tree than you intended. For small and medium apps I have no complaints.

    Riverpod, what I reach for now

    Riverpod is from the same author as Provider and fixes most of its pain. State lives outside the widget tree, so you can read it without a BuildContext, test it without pumping widgets, and catch mistakes at compile time instead of runtime. Providers are declared as top level variables and you watch them where you need them.

    import 'package:flutter_riverpod/flutter_riverpod.dart';
    
    final counterProvider = StateProvider<int>((ref) => 0);
    
    class CounterText extends ConsumerWidget {
      const CounterText({super.key});
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final count = ref.watch(counterProvider);
        return Text('Count is ' + count.toString());
      }
    }

    What I like most is how it handles async. A FutureProvider gives you loading, error, and data states without writing the boilerplate yourself, which connects nicely to the network work I describe in getting started with Flutter. For most new projects this is my default choice.

    Bloc, when discipline matters

    Bloc is heavier and more opinionated. You model your app as events going in and states coming out, and the strict separation makes large teams predictable. Every change to state is an explicit event with a clear handler, which makes the app easy to trace and to test. The cost is ceremony. Simple features need a lot of code.

    • Provider: low ceremony, tied to the tree, great for smaller apps
    • Riverpod: testable, compile safe, strong async support, my default
    • Bloc: verbose but predictable, shines on large teams and complex flows

    How I actually decide

    I match the tool to the team and the app. A solo project or a prototype gets Provider or Riverpod because I want to move fast. A large app with many contributors and complex business rules gets Bloc because the structure pays for itself in fewer surprises. The wrong move is picking the heaviest tool for the smallest job because a blog post told you it was best practice.

    Keep state out of the build method

    Whatever you choose, one rule holds across all of them. Never create or mutate state inside a build method, because build can run many times per second and you will create garbage or trigger loops. Keep state in the right place, listen to it, and let the framework rebuild. Doing this well also keeps your app smooth, which ties into Flutter performance optimization. Get state right and most other problems become smaller.

  • Code review best practices

    Code review best practices

    Code review is the single highest-leverage habit a team can have, and it is also the one most often done badly. I have been on the receiving end of reviews that felt like an interrogation and reviews that approved 800 lines with a thumbs-up emoji in four seconds. Both are failures. A good review catches real problems, spreads knowledge, and leaves the author feeling supported rather than judged.

    Review for the things humans are good at

    Do not spend your attention on formatting, import order, or whether a line is too long. A linter and an auto-formatter handle those, and they never get tired or grumpy. If your team argues about style in PR comments, you have a tooling gap, not a discipline problem. Configure the formatter, commit the config, and move on.

    What machines cannot check is whether the code is correct, whether it solves the actual problem, and whether someone six months from now will understand it. That is where my attention goes:

    • Does this do what the description says it does?
    • What happens at the edges: empty input, nulls, concurrent access, a network call that hangs?
    • Is there a simpler approach hiding behind this one?
    • Will the naming make sense to someone who was not in the room?

    Keep changes small enough to actually review

    The hard limit on review quality is size. Research and my own experience both say the same thing: past a few hundred lines, defect detection falls off a cliff because reviewers skim. When I get a giant PR, I ask the author to split it. A series of small, focused PRs gets genuine scrutiny on each one. This is also why I rebase and squash carefully before opening a PR, a habit I described in git workflow best practices.

    Comment like a colleague, not a compiler

    Tone is most of the job. The same point lands completely differently depending on phrasing. I ask questions instead of issuing verdicts, and I make it clear which comments are blocking versus optional.

    # Instead of:
    This is wrong.
    
    # Try:
    What happens here if items is empty? I think
    we'd index past the end. Could we guard with a
    length check, or is that case impossible upstream?
    
    # And label the small stuff:
    nit: could inline this, non-blocking

    Marking nits as non-blocking is a small thing that removes a huge amount of friction. The author knows what they must fix to merge versus what is just my taste. I also make a point of saying when something is genuinely good. A review that is only criticism trains people to dread the process.

    Review promptly and finish what you start

    A PR sitting unreviewed for two days blocks a person and rots as main moves underneath it. I treat review requests as near the top of my queue, ideally same day. The cost of a stalled PR compounds: the author context-switches away, then has to reload everything when feedback finally arrives.

    When I review, I try to give all my feedback in one pass rather than dribbling comments out over three rounds. Nothing is more demoralizing than fixing everything, getting re-reviewed, and discovering five new comments that were always visible.

    The author has homework too

    Reviews go faster when the author makes them easy. Before I request review I write a description that explains what changed and why, I leave inline comments on my own diff pointing out anything non-obvious, and I make sure CI is green. A good description can cut review time in half because the reviewer is not reverse-engineering intent from the diff.

    • State the problem, not just the solution.
    • Call out anything you are unsure about and want eyes on.
    • Include screenshots or sample output for anything user-facing.

    Disagree, then commit

    Sometimes the author and reviewer just see it differently. When the disagreement is about taste rather than correctness, I default to the author’s call after voicing my view once. Dragging a PR through five rounds over a stylistic preference burns goodwill that you will want later. Save the firm stands for things that actually matter: correctness, security, and the data contracts I care so much about in REST API design guidelines. Get those right and let the small stuff go.

  • How to set up CI/CD with GitHub Actions

    How to set up CI/CD with GitHub Actions

    GitHub Actions gets a bad reputation because people copy a giant YAML file from a blog post, it half works, and they never touch it again until it breaks. I want to show you the small, understandable version that I actually run on real projects.

    Where workflows live

    Every workflow is a YAML file inside .github/workflows. The directory name is not optional. Each file describes one or more jobs, and each job runs on a fresh virtual machine. The mental model that helped me most: a job is a clean laptop that boots, does exactly what you tell it, and then evaporates. Nothing persists between jobs unless you explicitly save it.

    A minimal but real pipeline

    Here is a workflow that runs on every push and pull request. It installs dependencies, runs the test suite, and builds the site. Note how the trigger, the runner, and the steps map to plain English:

    name: CI
    on:
      push:
        branches: [main]
      pull_request:
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: 20
              cache: npm
          - run: npm ci
          - run: npm test
          - run: npm run build

    Two things here earn their keep. The cache: npm line restores your dependency cache so installs drop from a minute to a few seconds. And npm ci instead of npm install respects your lockfile exactly, which means CI installs the same versions every time. Reproducibility is the whole point.

    Run jobs in parallel when you can

    If your lint, test, and type-check steps do not depend on each other, do not chain them. Split them into separate jobs and they run at the same time on different machines. Your feedback loop gets shorter, and the cost is the same since you pay for compute minutes either way. I only force sequence with needs when a later job genuinely requires an earlier one to finish, like deploy waiting on test.

    Adding deployment safely

    This is where I see the most mistakes. Deployment should only run on the main branch, never on pull requests, and it should depend on tests passing. The shape looks like this:

      deploy:
        needs: build
        if: github.ref == 'refs/heads/main'
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - run: npm ci
          - run: npm run build
          - name: Publish
            run: npx wrangler pages deploy dist --project-name my-site

    Wrangler needs a Cloudflare API token to publish. Store it as an encrypted repository secret under Settings, Secrets and variables, and expose it to the step through the job’s environment block. Never paste a token into the YAML itself, because the file is in your history forever. If you are deploying to Cloudflare specifically, the manual setup side is covered in deploying a static site to Cloudflare Pages.

    Secrets, the right way

    Repository secrets are encrypted and only decrypted at runtime inside the job. They are masked in logs, so if a token accidentally prints, GitHub redacts it. Reference them through the secrets context in your workflow’s environment mapping rather than echoing them. The golden rule: if it would let someone deploy or spend money, it is a secret, and it lives in GitHub’s secret store, not your code.

    Caching beyond dependencies

    You can cache more than node_modules. Build artifacts, compiled binaries, downloaded assets, anything expensive to recreate is a candidate. The actions/cache step takes a key, usually a hash of a lockfile or source directory, and restores the matching cache if it exists. Get the key right and your slow steps become instant. Get it wrong and you ship stale artifacts, so always include the relevant file hash in the key.

    Keep it boring

    My strongest advice is to resist cleverness. A workflow that anyone on the team can read in thirty seconds is worth more than a brilliant one only you understand. Add steps when you have a concrete reason, delete them the moment they stop pulling their weight, and pin your action versions so a surprise update never breaks a Friday deploy. If your pipeline also publishes content that needs to be searchable, you can fold that step in too, which pairs nicely with adding full-text search to a static site. Done well, CI/CD fades into the background and just keeps your main branch deployable.

  • Writing a simple memory allocator

    Writing a simple memory allocator

    The first time I wrote my own malloc, the whole concept of dynamic memory stopped feeling like a black box. An allocator is just a program that manages one big block of memory and hands out pieces of it on request. The hard parts are bookkeeping and fragmentation, not anything mystical. Let me walk through a minimal version.

    Where the memory comes from

    Your allocator needs raw memory to carve up. On Unix you get it from the kernel with sbrk, which moves the program break (the top of the heap), or with mmap for larger regions. sbrk is the simplest to reason about: call it with a positive number and the heap grows, returning a pointer to the new space.

    void *region = sbrk(4096);   // ask the kernel for one page
    if (region == (void *) -1) {
        // out of memory
    }

    The whole game is now: take that region and parcel it out, remembering which parts are in use and which are free.

    Block headers: the core trick

    The fundamental idea is to store metadata right before each block you hand out. When the caller asks for N bytes, you actually reserve N bytes plus the size of a small header. You return a pointer past the header, so the caller never sees it. When they call free with that pointer, you step backward to find the header again.

    typedef struct block {
        size_t size;          // payload size in bytes
        int free;             // 1 if available, 0 if in use
        struct block *next;   // next block in the list
    } block_t;
    
    #define HEADER_SIZE sizeof(block_t)

    I keep a linked list of these headers. To allocate, I walk the list looking for a free block big enough. This is the first-fit strategy: take the first block that works. Best-fit (the smallest block that fits) wastes less space but is slower to search. Both are fine for learning.

    The allocate path

    static block_t *head = NULL;
    
    void *my_malloc(size_t size) {
        block_t *cur = head;
        while (cur) {
            if (cur->free && cur->size >= size) {
                cur->free = 0;
                return (void *)(cur + 1);   // memory just after the header
            }
            cur = cur->next;
        }
        // nothing free, grow the heap
        block_t *blk = sbrk(HEADER_SIZE + size);
        if (blk == (void *) -1) return NULL;
        blk->size = size;
        blk->free = 0;
        blk->next = head;
        head = blk;
        return (void *)(blk + 1);
    }

    The expression cur + 1 is pointer arithmetic on a block_t pointer, so it jumps exactly past the header. If that line looks strange, my post on how pointers really work explains why adding one moves by a whole struct, not one byte.

    Freeing and the fragmentation problem

    Freeing is almost too easy: find the header and flip the free flag. The memory is not returned to the kernel, it is just marked reusable by the next allocation.

    void my_free(void *ptr) {
        if (!ptr) return;
        block_t *blk = (block_t *)ptr - 1;   // step back to the header
        blk->free = 1;
    }

    This naive version has a real flaw: fragmentation. Free a 100 byte block and a 100 byte block next to each other, then ask for 150 bytes, and my allocator fails even though 200 contiguous bytes are free. The fix is coalescing: when freeing, check if the neighboring blocks are also free and merge them into one larger block. Real allocators also split oversized blocks so a request for 16 bytes does not consume a 4 KB chunk.

    Alignment, the detail that bites later

    There is one correctness issue my toy version glosses over: alignment. The CPU expects an eight byte value to sit at an address divisible by eight, and on some architectures a misaligned access faults outright. A real allocator rounds every request up so the payload always starts on a properly aligned boundary, usually 16 bytes on a 64 bit system. My version happens to work because the header is already a multiple of the alignment, but the moment you start splitting blocks you have to round the sizes or you will hand back addresses that crash on certain reads. It is the kind of bug that hides for months and then surfaces only on one platform.

    Why this is worth building

    • You stop fearing malloc and start seeing it as a tunable component.
    • You understand why allocation patterns matter for performance, which ties directly into data-oriented design and CPU caches.
    • You see exactly why double-free and use-after-free corrupt memory: they scribble on these headers.

    Production allocators like jemalloc and tcmalloc add size classes, per-thread caches, and clever data structures, but the skeleton is what I just described. Knowing the skeleton is the difference between using memory and understanding it.

  • Cybersecurity for developers: the checklist I actually use

    Cybersecurity for developers: the checklist I actually use

    Security guides for developers tend to fail in one of two ways. They are either a wall of compliance language nobody reads, or a list of scary words with no instructions. This is the checklist I actually run before I ship something, written the way I would explain it to a teammate.

    Authentication: stop rolling your own

    If you are hashing passwords by hand in 2026, stop. Use a library that does argon2id or bcrypt with sane defaults. The number of ways to get this subtly wrong is large, and none of them show up in testing because a weak hash still logs the user in fine.

    Sessions over JWTs for most web apps. A server-side session you can revoke beats a stateless token you cannot. If you do use tokens, keep them short-lived and have a real refresh flow. The convenience of “I never have to check the database” turns into a problem the first time you need to kick someone out right now.

    Authorization is where the real bugs live

    Authentication asks who you are. Authorization asks what you are allowed to touch, and this is where most serious breaches happen. The classic one: an endpoint reads the user ID from the request body instead of the session, so I can edit my profile by sending your ID. It is called IDOR and it is everywhere.

    The fix is a habit, not a tool. Every time you load a record, ask “does the current user own this, and did I check?” Write that check at the data layer so it cannot be forgotten in a controller. The same care applies to AI features, by the way: an agent acting on a user’s behalf needs the user’s permissions, not the service account’s, a point I get into in agentic AI in cybersecurity.

    Input is hostile until proven otherwise

    SQL injection is old and still works because someone, somewhere, is still building queries with string concatenation. Use parameterized queries. Always. Your ORM probably does this for you, right up until you drop into a raw query for performance and forget.

    For anything that ends up in HTML, the framework’s default escaping is your friend. The danger is the moment you reach for the “render this as raw HTML” function. Every XSS bug I have ever fixed lived within a few lines of one of those calls.

    Secrets do not belong in the repo

    API keys, database passwords, signing secrets: none of these go in git, not even in a private repo, not even “temporarily.” Use environment variables or a secrets manager. Add a pre-commit scanner so a tired version of you cannot leak one at midnight.

    And rotate them when someone leaves or when a key has been sitting around for a year. A secret you cannot remember creating is a secret you should retire.

    The headers most apps forget

    A handful of HTTP response headers buy you a lot of safety for almost no effort. A strict Content-Security-Policy is the big one; it is annoying to tune and worth it. Add HSTS so browsers refuse to talk to you over plain HTTP, and set sensible cookie flags (HttpOnly, Secure, SameSite). These are a thirty-minute job that closes whole categories of attack.

    Dependencies are your attack surface too

    Most of your code is not your code. Run an audit on your dependencies, turn on automated update PRs, and actually read them instead of rubber-stamping. A compromised package in your build pipeline can do anything your build can do, which is usually a lot. This is one reason I keep build and runtime boundaries clean, something I write about in modern full-stack architecture.

    Run it before you ship it

    Point a scanner at your own app before an attacker does. Even a free one will catch the obvious holes. Pair that with the habit of testing the unhappy paths: what happens when I send the wrong type, a huge payload, someone else’s ID, a missing token. The bugs hide in the cases you did not plan for.

    None of this is exotic. It is the same ten things, done every time, that separate apps that get breached from apps that do not. If you want to go further into building secure systems with AI in the loop, the engineering practices in practical AI engineering are the natural next read.

  • Memory safety with Rust ownership and borrowing

    Memory safety with Rust ownership and borrowing

    After years of chasing dangling pointers and double-frees in C, Rust felt like someone had finally written down the rules I was holding in my head and made the compiler enforce them. Rust gives you manual control over memory with no garbage collector, yet it statically prevents the entire class of memory bugs that plague C. The mechanism is ownership and borrowing, and it is simpler than its reputation suggests.

    The three ownership rules

    Everything in Rust starts from three rules the compiler enforces:

    • Each value has exactly one owner, a single variable responsible for it.
    • There can only be one owner at a time.
    • When the owner goes out of scope, the value is dropped and its memory freed.

    That last rule is the quiet genius. There is no free to call and no garbage collector to run. The compiler knows exactly where each value’s scope ends and inserts the cleanup for you. This is the same scope-based lifetime you get from the stack, which I described in stack vs heap: how memory actually works, except Rust extends it to heap data too.

    Moves, not copies

    Because there is only one owner, assigning a heap value to another variable moves ownership rather than copying the data. The old variable becomes invalid, and the compiler will reject any use of it.

    let s1 = String::from("hello");
    let s2 = s1;            // ownership moves from s1 to s2
    // println!("{}", s1);  // compile error: s1 was moved
    println!("{}", s2);     // fine, s2 owns the data now

    This single rule eliminates double-free at compile time. In C, two pointers to the same heap block both think they should free it. In Rust, only one variable owns the data, so it gets freed exactly once. The whole category of bug is gone before the program runs.

    Borrowing instead of moving

    Moving everywhere would be painful, so Rust lets you borrow a value by taking a reference. A reference is a pointer that does not own what it points to. The borrow checker enforces one more set of rules to keep references safe:

    • You can have any number of immutable references at once.
    • Or exactly one mutable reference.
    • But never both at the same time.
    fn main() {
        let mut data = vec![1, 2, 3];
        let r1 = &data;        // immutable borrow
        let r2 = &data;        // another one, fine
        println!("{} {}", r1[0], r2[0]);
    
        let m = &mut data;     // mutable borrow, allowed now r1/r2 are done
        m.push(4);
    }

    This “one writer or many readers” rule is what prevents data races and use-after-free. You cannot hold a reference into a vector while another piece of code reorganizes it, because that would require a mutable and an immutable borrow simultaneously. The compiler refuses to build it.

    Lifetimes make dangling impossible

    The borrow checker also tracks how long each reference lives and guarantees a reference never outlives the data it points to. The classic dangling pointer from C, returning a reference to a local, simply does not compile.

    fn dangle() -> &String {
        let s = String::from("oops");
        &s    // error: s is dropped here, the reference would dangle
    }

    If you have read how pointers really work, you know this is exactly the bug that produces silent corruption in C. Rust turns it into a compile error with a clear message.

    The escape hatch and why it matters

    Rust is not naive about the fact that some code genuinely needs to do unsafe things: dereference raw pointers, call into C, talk to hardware. It does not pretend these never happen. Instead it walls them off behind the unsafe keyword. Inside an unsafe block you get the raw pointer operations that C gives you everywhere, but the block is a visible marker. When something does go wrong with memory, you have a small audited surface to inspect instead of the whole program. The standard library is built on these blocks, carefully reviewed, so the safe code on top inherits the guarantees. That layering is the practical reason Rust scales: most code stays safe, and the unsafe parts are few and labeled.

    What you give up and what you get

    The cost is real: the borrow checker rejects programs that would actually be fine, and you spend time restructuring code to satisfy it. That learning curve is the famous “fighting the borrow checker” phase. What you get in return is C-level performance and control with none of the memory unsafety, verified before the program ever runs. After living in both worlds, I think the tradeoff is worth it for anything where correctness matters. Rust did not invent these rules. It just made the compiler the one who remembers them so I do not have to.

  • How to add full-text search to a static site

    How to add full-text search to a static site

    The first objection I hear is that static sites cannot have search because there is no server to query. That is wrong. You can build a search index when the site is generated and ship it as a plain file, then search it entirely in the browser. For anything up to a few thousand documents, it is fast enough that users assume there is a backend.

    Why client-side search works

    The trick is moving the work to build time. While my generator is already looping over every page to render HTML, it is trivial to also collect the title, URL, and body text of each one into a list. That list becomes a JSON file. The browser downloads it once, builds an in-memory index, and every keystroke after that is instant because nothing leaves the device. No database, no API, no per-query cost.

    Build the index at generation time

    During the build, I strip HTML tags from each page and push a small record into an array. Keep the body text trimmed; you do not need every word, and a leaner index downloads faster. Here is the gist of it:

    const index = pages.map(page => ({
      title: page.title,
      url: page.url,
      excerpt: page.excerpt,
      body: page.text.slice(0, 2000)
    }));
    
    fs.writeFileSync('dist/search-index.json', JSON.stringify(index));

    Writing this file is just one more step in the same pipeline that produces your pages, so it slots naturally into a build you may already run through CI/CD with GitHub Actions. The index ships alongside your HTML to the same edge network.

    Pick a search library, or write the dumb version

    For small sites you genuinely can write your own matcher in a dozen lines. Lowercase everything, split the query into words, and score documents by how many words they contain. It works. But the moment you want typo tolerance, prefix matching, or relevance ranking, reach for a library. I like Fuse.js for fuzzy matching and MiniSearch when I want proper full-text scoring. Both are tiny and run in the browser without a build step.

    My rule of thumb is to start with the hand-written version and only upgrade when users complain. Most of the time the simple matcher is more than enough, and you avoid shipping a dependency you do not need. When you do switch to a library, the index format stays the same, so the migration is a few lines, not a rewrite.

    import MiniSearch from 'minisearch';
    
    const res = await fetch('/search-index.json');
    const docs = await res.json();
    
    const mini = new MiniSearch({
      fields: ['title', 'body'],
      storeFields: ['title', 'url', 'excerpt']
    });
    mini.addAll(docs);
    
    const results = mini.search('cloudflare deploy', { fuzzy: 0.2 });

    Wire it to the input

    Hook a listener to your search box, but do not search on every single keystroke. Debounce it by 150 milliseconds or so, otherwise a fast typist fires a dozen searches for one word. On each debounced event, run the query, take the top handful of results, and render them as a list of links. Show the title and the excerpt so people can tell which result they want before clicking.

    • Debounce input so you search on a pause, not on every letter.
    • Limit to the top eight or ten results; nobody scrolls a search dropdown.
    • Highlight the matched term in the result so the relevance is obvious.
    • Handle the empty state and the no-results state explicitly.

    Load the index lazily

    Do not download the search index on page load. Most visitors never search, so paying that cost upfront is wasteful. I fetch the JSON the first time someone focuses the search box, cache it in a variable, and reuse it. The first search has a tiny delay while the file arrives, every search after is instant, and people who never search never pay a byte for it. This keeps your initial load lean, which matters for the same reasons I obsess over in optimizing images for the web.

    When to stop and use a service

    Client-side search has a ceiling. Past roughly ten thousand documents the index gets large enough that downloading and parsing it hurts, especially on phones. At that scale I switch to a hosted search service that exposes an API. But honestly, most blogs and docs sites never come close to that limit. Build the index, ship the JSON, search in the browser, and you get a feature that feels expensive for almost no cost and zero servers to maintain.

  • How to build a REST API with rate limiting

    How to build a REST API with rate limiting

    Any API exposed to the public will eventually get hammered, whether by a buggy client looping on an error, a scraper, or someone outright abusing it. Rate limiting is how you protect your service and keep it fair for everyone. I have added it to plenty of APIs, and the concepts are simpler than the jargon suggests.

    Pick an algorithm

    There are a few classic approaches and they trade off accuracy against cost. A fixed window counts requests per time bucket, say 100 per minute, and resets on the boundary. It is dead simple but allows bursts at the edges, since a client can fire 100 requests at the end of one minute and 100 at the start of the next. A sliding window smooths that out by weighting the previous window. A token bucket refills tokens at a steady rate and lets clients spend them in bursts up to a cap, which feels the most natural for real traffic.

    • Fixed window: easiest to build, allows edge bursts.
    • Sliding window: more accurate, slightly more bookkeeping.
    • Token bucket: handles bursts gracefully, my usual default.

    A simple token bucket

    The idea is that each client has a bucket of tokens. Every request costs one token, and tokens refill over time. If the bucket is empty, the request is rejected. Here is the core logic, ignoring storage for a moment:

    function allow(bucket, now, rate, capacity) {
      const elapsed = (now - bucket.last) / 1000;
      bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * rate);
      bucket.last = now;
      if (bucket.tokens >= 1) {
        bucket.tokens -= 1;
        return true;
      }
      return false;
    }

    That function refills based on how much time has passed, caps the bucket so tokens do not accumulate forever, and spends one token per allowed request. It is maybe fifteen lines and it covers the vast majority of real needs.

    Identify the client correctly

    Rate limiting is only as good as your idea of who a client is. For authenticated traffic, key the limit on the API key or user ID, which is reliable. For anonymous traffic you fall back to IP address, which is imperfect because users behind the same network share an IP and proxies can spoof it. If you are behind a proxy or CDN, read the forwarded header, but only trust it when the request genuinely came through your infrastructure. Getting the key wrong means you either punish innocent users or fail to stop abusers.

    Respond the right way

    When you reject a request, do it with the correct HTTP status and helpful headers so well-behaved clients can adapt. The status is 429 Too Many Requests. Always include a Retry-After header telling the client how long to wait, and the standard rate limit headers so they can self-throttle before hitting the wall:

    HTTP/1.1 429 Too Many Requests
    Retry-After: 30
    RateLimit-Limit: 100
    RateLimit-Remaining: 0
    RateLimit-Reset: 30
    Content-Type: application/json
    
    { "error": "rate limit exceeded, retry in 30 seconds" }

    A good client reads those headers and backs off politely. A bad one ignores them, but at least you gave it the chance, and you have a clean record of why you said no.

    Where to store the counters

    An in-memory counter works for a single server and falls apart the moment you scale to two, because each instance has its own view. For anything distributed you need shared state. Redis is the classic choice; it is fast and has atomic increment operations that make this easy. On the edge, a key-value store like Cloudflare’s KV or Durable Objects does the same job close to the user. Whatever you pick, the counter update must be atomic, or two simultaneous requests can both read the same count and both get through.

    Layer it with other defenses

    Rate limiting is one layer, not the whole wall. Pair it with authentication, input validation, and sensible timeouts. Put it as early in the request lifecycle as you can, ideally before any expensive work, so a flood of blocked requests costs you almost nothing. If you are deploying this kind of service yourself, the same edge platform I describe in deploying to Cloudflare Pages can run the API and its rate-limit store together, and you can ship it confidently through a GitHub Actions pipeline. Build the limiter once, keep it boring, and it quietly does its job under load.

  • Backend scalability patterns that actually hold up under load

    Backend scalability patterns that actually hold up under load

    Most backends do not fall over because of one giant problem. They fall over because of a dozen small assumptions that were fine at a thousand requests a day and quietly stop being fine at a million. I have spent a good chunk of my career chasing those assumptions down, usually at three in the morning, and the patterns below are the ones I keep coming back to. None of them are exotic. The skill is knowing which one to apply and when to stop.

    Scale up before you scale out

    The first question I ask when a service is struggling is whether I can just give it a bigger machine. Vertical scaling gets a bad reputation because it has a ceiling, but that ceiling is much higher than people think. A modern instance with 64 cores and 256 gigabytes of memory will carry an enormous amount of traffic, and you get it without touching your code, your deployment, or your mental model of the system.

    Horizontal scaling is where you add more machines and spread the work across them. It scales further, but it forces decisions on you. State has to live somewhere shared. Requests have to be routed. Failures multiply because now you have ten things that can break instead of one. My rule is simple. I scale up until it gets expensive or I hit the instance ceiling, and only then do I scale out. Reaching for a fleet of tiny nodes on day one is how you end up debugging distributed systems problems you did not need to have yet.

    Statelessness is the thing that makes everything else possible

    You cannot spread traffic across many servers if any one server is secretly special. The moment a request only works because it landed on the same box that handled the previous request, horizontal scaling is dead. This is why I treat statelessness as the foundation rather than an optimization.

    In practice that means no session data in local memory, no uploaded files sitting on the local disk, no in-process counters that matter. Push session state into Redis or a signed token. Push files into object storage. Push anything durable into a database. When every application node is interchangeable, a load balancer can send a request to any of them, you can add and remove nodes freely, and a crashed node costs you nothing but the in-flight requests it was holding.

    If you want the wider context for how this fits into a system, I wrote about it in modern fullstack architecture, where statelessness shows up again as a precondition for clean deploys.

    Load balancing and how requests find a home

    Once you have several interchangeable nodes, something has to decide where each request goes. A load balancer sits in front and distributes traffic. Round robin is the obvious starting point and it is fine for uniform workloads, but it gets dumb fast when requests vary in cost. Least connections is usually a better default because it sends new work to whichever node is least busy right now.

    The part people forget is health checks. A load balancer is only as good as its ability to notice a sick node and stop sending it traffic. I always configure active health checks with a real endpoint that touches the critical dependencies, not a route that returns 200 no matter what.

    upstream api_backend {
        least_conn;
        server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
        server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
        server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
    }
    
    server {
        listen 443 ssl;
        location / {
            proxy_pass http://api_backend;
            proxy_next_upstream error timeout http_502 http_503;
        }
        location /healthz {
            access_log off;
            proxy_pass http://api_backend;
        }
    }

    Caching, the highest leverage move you have

    Nothing buys you headroom faster than not doing work twice. Caching is the highest leverage tool in this whole list, and it operates at several layers. There is the CDN at the edge for static assets and cacheable responses. There is an application cache like Redis or Memcached for computed results and hot rows. There is the database query cache and the operating system page cache below that. Each layer you can serve from is a layer of work the layers beneath it never see.

    The hard part is never reading from a cache. It is invalidation. A stale cache is worse than no cache because it lies confidently. I lean heavily on time based expiry because it is predictable, and I only reach for event based invalidation when staleness genuinely hurts. Cache-aside is my default pattern: check the cache, on a miss go to the source, then write the result back with a TTL.

    def get_user(user_id):
        key = "user:" + str(user_id)
        cached = redis.get(key)
        if cached is not None:
            return deserialize(cached)
        user = db.query("SELECT * FROM users WHERE id = %s", user_id)
        redis.setex(key, 300, serialize(user))
        return user

    Two failure modes are worth naming. A cache stampede happens when a popular key expires and a thousand requests all miss at once and slam the database together. You fight it with jittered TTLs or a short lock so only one request rebuilds the value. The second is unbounded growth, which you handle with an eviction policy like LRU and a memory cap.

    Queues, or how to stop doing slow work in the request path

    A lot of what makes requests slow does not need to happen while the user waits. Sending an email, resizing an image, generating a report, syncing to a third party. If the caller does not need the result right now, get it out of the request path and onto a queue. The web tier accepts the job, hands it to a broker like RabbitMQ or SQS, returns immediately, and a pool of workers chews through the backlog at its own pace.

    This does two things. It makes your latency predictable because the request no longer waits on slow downstream work. And it absorbs spikes, because a queue is a buffer. When traffic triples for an hour, the queue grows and the workers catch up afterward instead of the whole system melting. The thing you must get right is idempotency. Queues redeliver. A message will be processed more than once eventually, so every worker has to be safe to run twice on the same input.

    • Make handlers idempotent with a dedupe key or an upsert so a redelivery is harmless.
    • Set a dead letter queue so poison messages stop blocking the line and land somewhere you can inspect them.
    • Watch queue depth as a first class metric. A growing queue is the earliest warning that workers cannot keep up.

    Backpressure and graceful degradation

    Scaling is not only about handling more. It is about failing well when you cannot. A system under genuine overload should shed load deliberately rather than collapse. I put rate limits on the edges, timeouts on every outbound call, and circuit breakers around flaky dependencies so a slow downstream service does not pile up threads and take the whole process with it.

    Graceful degradation means deciding ahead of time what to drop first. If the recommendation service is down, show a generic list instead of an error page. If the cache is cold, serve slightly stale data rather than nothing. The users who get a degraded experience are far happier than the users who get a 500.

    Where the database fits

    Everything above buys time, but it eventually runs into the database, which is almost always the real bottleneck. Caching reduces reads, queues smooth writes, but past a certain point the data layer itself has to scale. That is a big enough topic that I gave it its own write up on scaling databases with replication and sharding, and if your schema is fighting you before you even get there, fix that first with solid schema design.

    The order I actually apply these

    If I had to compress all of this into a sequence, it would be this. Make the service stateless so it can scale at all. Scale up until it is uneconomical. Put a load balancer and more nodes in front. Add caching at the layers that hurt most. Move slow work onto queues. Add backpressure so overload degrades instead of crashes. Then, and usually only then, do the harder work of scaling the database. Measure at every step, because the bottleneck is rarely where your intuition says it is, and adding capacity to the wrong layer just moves the queue around.