client-go Deep Dive: DeltaFIFO — The Event Queue Behind Informer

In the previous article we saw how Reflector calls List/Watch and feeds events downstream. The immediate destination of those events is DeltaFIFO — the event queue that sits at the heart of the Informer framework.

Source: k8s.io/client-go/tools/cache/delta_fifo.go

1. What Is DeltaFIFO?

The name has two parts:

Part Meaning
FIFO First-In-First-Out queue — supports Add, Update, Delete, List, Pop operations
Delta Each entry stores not just the object, but also the type of change (Added / Updated / Deleted / Sync / Replaced)

Together, DeltaFIFO is a queue where each item carries both what changed and how it changed — giving downstream consumers the full context they need to act correctly.

2. The Data Structure

// k8s.io/client-go/tools/cache/delta_fifo.go
type DeltaFIFO struct {
    lock sync.RWMutex
    cond sync.Cond             // used to block/unblock consumers

    items map[string]Deltas    // key → list of deltas for that object
    queue []string             // ordered list of keys (FIFO ordering)

    populated              bool
    initialPopulationCount int  // tracks how many items came from the initial List

    keyFunc      KeyFunc         // computes the key for a given object
    knownObjects KeyListerGetter // reference to Indexer (for deletion detection)

    closed                bool
    emitDeltaTypeReplaced bool
}

type Deltas []Delta

type Delta struct {
    Type   DeltaType    // Added | Updated | Deleted | Replaced | Sync
    Object interface{}  // the actual Kubernetes resource object
}

The Three Core Fields

queue: ["pod/default/nginx", "pod/default/redis", "deploy/default/app"]
         ↑ ordered slice of object keys (FIFO order)

items: {
  "pod/default/nginx":  [ {Added, PodObj_v1}, {Updated, PodObj_v2} ],
  "pod/default/redis":  [ {Deleted, PodObj_v1} ],
  "deploy/default/app": [ {Added, DeployObj_v1} ],
}
         ↑ map: key → []Delta (all pending changes for that object)

Delta: { Type: "Updated", Object: <Pod nginx v2> }
         ↑ a single change event: what happened + the object snapshot

Visualizing the Structure

┌─────────────────────────────────────────────────────────────┐
│                        DeltaFIFO                            │
│                                                             │
│  queue (FIFO order):                                        │
│  ┌──────────────────┬──────────────────┬─────────────────┐  │
│  │ "pod/default/    │ "pod/default/    │ "deploy/default │  │
│  │  nginx"          │  redis"          │  /app"          │  │
│  └──────────────────┴──────────────────┴─────────────────┘  │
│          │                   │                  │            │
│          ▼                   ▼                  ▼            │
│  items (map):                                               │
│  ┌──────────────────┐ ┌────────────────┐ ┌──────────────┐  │
│  │ [{Added, nginx}  │ │[{Deleted,      │ │[{Added,      │  │
│  │  {Updated,nginx}]│ │  redis}]       │ │  deploy}]    │  │
│  └──────────────────┘ └────────────────┘ └──────────────┘  │
└─────────────────────────────────────────────────────────────┘

Key design insight: queue maintains ordering (which object to process next), while items accumulates all pending deltas per object. Multiple changes to the same object are batched — but never reordered relative to other objects.

3. Producing Messages: queueActionLocked

Every time Reflector calls syncWith() (from List) or watchHandler() (from Watch), it ultimately calls queueActionLocked to enqueue an event:

func (f *DeltaFIFO) queueActionLocked(actionType DeltaType, obj interface{}) error {
    // ① Compute the object's key (e.g. "default/nginx")
    id, err := f.KeyOf(obj)
    if err != nil {
        return KeyError{obj, err}
    }

    // ② Append new Delta to the existing list for this key
    oldDeltas := f.items[id]
    newDeltas := append(oldDeltas, Delta{actionType, obj})

    // ③ Deduplicate consecutive identical events
    newDeltas = dedupDeltas(newDeltas)

    if len(newDeltas) > 0 {
        // ④ If this key is new, add it to the queue (maintain FIFO order)
        if _, exists := f.items[id]; !exists {
            f.queue = append(f.queue, id)
        }
        // ⑤ Update items map with the new delta list
        f.items[id] = newDeltas

        // ⑥ Wake up all blocked consumers
        f.cond.Broadcast()
    }
    return nil
}

Production flow:

Reflector (r.syncWith / r.watchHandler)
     │
     ▼
queueActionLocked(actionType, obj)
     │
     ├── ① KeyOf(obj)              → compute key (e.g. "default/nginx")
     ├── ② append Delta            → add {type, obj} to existing delta list
     ├── ③ dedupDeltas()           → remove redundant consecutive events
     ├── ④ queue = append(key)     → add key to FIFO queue (if new)
     ├── ⑤ items[key] = newDeltas  → store updated delta list
     └── ⑥ cond.Broadcast()        → wake up Pop() consumers

Delta Types

const (
    Added    DeltaType = "Added"     // object newly appeared (from Watch or initial List)
    Updated  DeltaType = "Updated"   // object was modified
    Deleted  DeltaType = "Deleted"   // object was removed
    Replaced DeltaType = "Replaced"  // full re-list replaced the object (resync)
    Sync     DeltaType = "Sync"      // periodic resync — object unchanged but re-delivered
)

Deduplication: dedupDeltas

dedupDeltas prevents redundant back-to-back Deleted events for the same object:

Before dedup:  [{Updated, obj}, {Deleted, obj}, {Deleted, obj}]
After dedup:   [{Updated, obj}, {Deleted, obj}]

Only the last two deltas are considered for deduplication — older history is always preserved.

4. Consuming Messages: Pop

The consumer side is driven by the Informer’s internal Controller, which calls Pop() in its processLoop:

func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error) {
    f.lock.Lock()
    defer f.lock.Unlock()

    for {
        // ① Block if queue is empty — wait for producer to signal
        for len(f.queue) == 0 {
            if f.closed {
                return nil, ErrFIFOClosed
            }
            f.cond.Wait()   // releases lock and sleeps until cond.Broadcast()
        }

        // ② Dequeue the first key (FIFO order)
        id := f.queue[0]
        f.queue = f.queue[1:]

        if f.initialPopulationCount > 0 {
            f.initialPopulationCount--
        }

        // ③ Retrieve and remove the delta list for this key
        item, ok := f.items[id]
        if !ok {
            continue  // key was already processed (race condition guard)
        }
        delete(f.items, id)

        // ④ Pass the delta list to the callback function (HandleDeltas)
        err := process(item)

        // ⑤ If processing failed, re-enqueue for retry
        if e, ok := err.(ErrRequeue); ok {
            f.addIfNotPresent(id, item)
            err = e.Err
        }

        return item, err
    }
}

Consumption flow:

Controller.processLoop()
     │
     └── DeltaFIFO.Pop(HandleDeltas)
           │
           ├── ① cond.Wait()       → sleep if queue empty
           ├── ② dequeue key       → take first key from FIFO queue
           ├── ③ delete(items,id)  → remove delta list from map
           ├── ④ process(item)     → call HandleDeltas callback
           └── ⑤ on error:
                 addIfNotPresent() → re-enqueue for retry

5. HandleDeltas: Routing Events Downstream

Once Pop() delivers a delta list to the callback, HandleDeltas routes each event to two destinations:

// k8s.io/client-go/tools/cache/shared_informer.go
func (s *sharedIndexInformer) HandleDeltas(obj interface{}) error {
    s.blockDeltas.Lock()
    defer s.blockDeltas.Unlock()

    for _, d := range obj.(Deltas) {
        switch d.Type {

        case Sync, Replaced, Added, Updated:
            if old, exists, err := s.indexer.Get(d.Object); err == nil && exists {
                // Object already in cache → Update
                s.indexer.Update(d.Object)

                isSync := (d.Type == Sync) ||
                    (d.Type == Replaced && sameResourceVersion(d.Object, old))

                // Route to ShareInformer listeners
                s.processor.distribute(updateNotification{oldObj: old, newObj: d.Object}, isSync)
            } else {
                // Object not in cache → Add
                s.indexer.Add(d.Object)
                s.processor.distribute(addNotification{newObj: d.Object}, false)
            }

        case Deleted:
            s.indexer.Delete(d.Object)
            s.processor.distribute(deleteNotification{oldObj: d.Object}, false)
        }
    }
    return nil
}

The Two Destinations

HandleDeltas(Deltas)
     │
     ├── ① Indexer (local cache)
     │     ├── Added/Updated/Replaced/Sync → indexer.Add() or indexer.Update()
     │     └── Deleted                     → indexer.Delete()
     │
     └── ② processor.distribute() (ShareInformer fan-out)
           ├── addNotification    → triggers OnAdd  callbacks
           ├── updateNotification → triggers OnUpdate callbacks
           └── deleteNotification → triggers OnDelete callbacks
                      │
                      ▼
             informer.AddEventHandler(ResourceEventHandlerFuncs{
                 AddFunc:    func(obj) { workqueue.Add(key) },
                 UpdateFunc: func(old, new) { workqueue.Add(key) },
                 DeleteFunc: func(obj) { workqueue.Add(key) },
             })

6. The Complete DeltaFIFO Lifecycle

┌─────────────────────────────────────────────────────────────────┐
│                     DeltaFIFO Lifecycle                         │
│                                                                 │
│  PRODUCER (Reflector)                                           │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  r.syncWith()      → queueActionLocked(Added, obj)       │   │
│  │  r.watchHandler()  → queueActionLocked(Updated/Deleted…) │   │
│  └──────────────────────────────────────────────────────────┘   │
│                          │ cond.Broadcast()                     │
│                          ▼                                      │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │                    DeltaFIFO                             │   │
│  │  queue: [key1, key2, key3, ...]   (FIFO order)           │   │
│  │  items: {key1: [Δ,Δ], key2: [Δ], key3: [Δ,Δ,Δ]}        │   │
│  └──────────────────────────────────────────────────────────┘   │
│                          │ cond.Wait → unblock                  │
│                          ▼                                      │
│  CONSUMER (Controller.processLoop)                              │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  Pop(HandleDeltas)                                       │   │
│  │    → HandleDeltas                                        │   │
│  │        ├── Indexer.Add/Update/Delete  (local cache)      │   │
│  │        └── processor.distribute()    (event callbacks)   │   │
│  └──────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

7. Summary

Concept Detail
queue Ordered slice of object keys — guarantees FIFO processing order
items Map of key → []Delta — accumulates all pending changes per object
Delta {Type, Object} — carries both the change type and the full object snapshot
queueActionLocked Producer entry point — appends delta, deduplicates, signals consumers
dedupDeltas Removes redundant back-to-back Deleted events
Pop Consumer entry point — blocks when empty, dequeues FIFO, calls HandleDeltas
HandleDeltas Routes each delta to Indexer (storage) AND processor.distribute (callbacks)
cond.Broadcast/Wait Efficient producer-consumer synchronization — no busy polling

DeltaFIFO is the reliable handoff point between Reflector (producer) and the Informer’s internal Controller (consumer). Its dual structure — an ordered queue for sequencing plus a map for delta accumulation — ensures that no event is lost, no object is processed out of order, and downstream consumers always receive the full change context.

Next in this series: Indexer: Fast In-Memory Resource Storage with Thread-Safe Indexing (Part 4)

Follow the series for more deep dives into Kubernetes development.

3 Pricing Page Rewrites: What Finally Converted

We rewrote the Applighter pricing page three times in eleven months. Here is what each version was, why it failed (or worked), and the structure we kept.

For context: we sell production-ready React Native templates. Each one is a full Expo app with a Supabase backend, NativeWind styling, AI integration where relevant, and a Stripe-powered license grant on purchase. Pricing is one-time, $79 per template, no subscription. That detail matters because two of our three rewrites pretended otherwise.

Version 1: the SaaS-tier menu

The first page was the one every founder builds in week one.

| Starter $79  | Pro $79      | Team $79     |
|--------------|--------------|--------------|
| ✓ Source     | ✓ Source     | ✓ Source     |
| ✓ Backend    | ✓ Backend    | ✓ Backend    |
|              | ✓ Most       |              |
|              |   Popular    |              |

Three columns. “Most Popular” badge. Monthly/annual toggle (we don’t sell monthly anything). It looked professional. It looked familiar.

Conversion was bad. Support email volume was worse. The questions were:

  • “Is the $79 per template or for the whole library?”
  • “Do I have to pay every month?”
  • “If I pay $79, do I get the source code or just access to a dashboard?”

The page was telling buyers a SaaS story. The product was a zip of TypeScript and a Supabase project. Mismatch.

Lesson: Don’t borrow the pricing UX of a different business model.

Version 2: the comparison wall

When buyers are confused, every founder’s reflex is give them more information. We built a 32-row feature matrix comparing Applighter to “DIY,” “UI templates,” and “marketplace bundles.”

const featureMatrix = [
  { feature: "TypeScript",          applighter: true,  diy: "depends", ui: true,    bundle: true  },
  { feature: "Supabase backend",    applighter: true,  diy: false,     ui: false,   bundle: false },
  { feature: "Auth",                applighter: true,  diy: "build it", ui: false,  bundle: "mock" },
  { feature: "Commercial license",  applighter: true,  diy: "n/a",      ui: "ext",  bundle: "ext" },
  // ... 28 more rows
];

Session recordings: median scroll-past in under three seconds.

The table was answering questions nobody was asking. The questions buyers actually had — can I use this commercially, what happens after I pay, what if it doesn’t work for my project — were buried in a footer FAQ link nobody clicked.

Worse, the comparison framed the product as defensive. Buyers don’t pay $79 to validate your competitive position.

Lesson: Read your support inbox before you read CRO articles.

Version 3: one product, three license sizes

The current page is shorter than either previous version. Top to bottom:

  1. Hero with the product, not the price. Screenshot of the template running on an iPhone. The price exists, but it isn’t the headline.
  2. License picker, not a tier ladder. Single / Multiple / Enterprise — described by who is buying, not by features unlocked. Every license includes the same source code.
  3. What’s included, plainly. Six bullets. No checkmarks. No comparisons.
  4. Refund policy next to the buy button. Seven-day, no-questions. The single biggest move on V3.
  5. FAQ above the fold of the second screen. Pulled directly from real support tickets, in the buyer’s own words.
  6. One soft secondary path: browse other templates.

The implementation:

// app/buy/page.tsx (sketch)
export default async function BuyPage() {
  const tiers = await getLicenseTiers();
  return (
    <>
      <Hero product={product} />
      <LicensePicker tiers={tiers} />
      <RefundLine policy="7-day money-back" />
      <BuyButton />
      <Faq source={faqJson} />
      <RelatedTemplates />
    </>
  );
}

License tiers are stored in Postgres and read at request time, so we can adjust per-template without a deploy. The FAQ is read from data/faq.json and used on both the homepage and the buy page so the messaging stays consistent.

What conversion actually moved

Pricing page Layout Optimized for What buyers did
V1: SaaS tier menu Starter / Pro / Team Looking like a “real” company Asked support if it was a subscription
V2: Comparison wall 32-row feature matrix Defending against alternatives Scrolled past in 3 seconds
V3: License picker Hero → license sizes → FAQ → refund Removing friction the buyer named Bought, or refunded inside 7 days (rarely)
  • V1 → V2 made things worse. More info to a confused buyer = more confusion.
  • V2 → V3 was the largest jump. Refund line + license picker carried most of it.
  • Refund rate dropped after V3, not V2. Refunds correlate with surprise.
  • Support ticket volume on pricing questions fell roughly in half.

We did not run A/B tests. Traffic on a $79 product wasn’t high enough for significance in a useful timeframe. We talked to buyers, watched email, and changed the page.

Five lessons that survived

  1. Match the pricing UX to the business model. A one-time digital product is not a SaaS.
  2. The buyer’s questions are not your competitor’s questions. Read the inbox.
  3. The refund policy is part of the offer. Surface it.
  4. Less ships faster than more. Every rewrite removed something.
  5. Ship a page, don’t ship an A/B test. At our volume, ten buyer conversations beat a multivariate framework.

What we would do differently

Skip V1 and V2. Start at V3. The shape of the right page was sitting in the support inbox the whole time.

If you want to see the structure in production, the Applighter template catalog is built on the V3 pattern — same hero-then-license-picker-then-refund-line shape on every product page.

For the React Native + Supabase + Expo stack underneath, the relevant docs are Expo, Supabase, and Stripe Checkout.

If you’ve rewritten your own pricing page and learned something the slow way — especially if you sell a one-time digital product — drop a comment with what moved the needle for you. Curious whether the “refund line next to CTA” pattern holds for other indie devs or whether it’s specific to our buyer.

client-go Deep Dive: Reflector — How Kubernetes Syncs Resources from the API Server

In the previous article we saw that Informer’s core mechanism is List/Watch. The component responsible for executing that mechanism is Reflector. Every time an Informer starts up, it’s Reflector that connects to the API Server, fetches the full resource snapshot, and then keeps watching for changes.

Source: k8s.io/client-go/tools/cache/reflector.go

1. Where Reflector Fits

Informer starts
     │
     ▼
┌──────────────────────────────────────────────────┐
│                   Reflector                      │
│                                                  │
│  Phase 1: LIST                                   │
│  ├── call listerWatcher.List()                   │
│  ├── get ResourceVersion                         │
│  ├── extract object list                         │
│  └── syncWith() → store all objects in DeltaFIFO │
│                                                  │
│  Phase 2: WATCH                                  │
│  ├── call listerWatcher.Watch()                  │
│  ├── receive incremental events (HTTP chunked)   │
│  └── watchHandler() → push events to DeltaFIFO  │
└──────────────────────────────────────────────────┘
     │
     ▼
DeltaFIFO (event queue)

2. The Reflector Struct

// k8s.io/client-go/tools/cache/reflector.go
type Reflector struct {
    name             string
    expectedTypeName string
    expectedType     reflect.Type          // the resource type being watched (e.g. *v1.Pod)
    expectedGVK      *schema.GroupVersionKind

    store            Store                 // DeltaFIFO — where events are written
    listerWatcher    ListerWatcher         // the actual List/Watch implementation

    backoffManager         wait.BackoffManager  // retry backoff for Watch reconnects
    initConnBackoffManager wait.BackoffManager  // backoff for initial connection

    resyncPeriod     time.Duration         // how often to force a full resync (0 = never)
    ShouldResync     func() bool           // optional: custom resync decision function

    clock            clock.Clock
    paginatedResult  bool

    // ResourceVersion tracking — critical for Watch correctness
    lastSyncResourceVersion              string
    isLastSyncResourceVersionUnavailable bool
    lastSyncResourceVersionMutex         sync.RWMutex

    WatchListPageSize  int64
    watchErrorHandler  WatchErrorHandler
}

Key fields explained:

Field Purpose
store The DeltaFIFO queue — Reflector writes all events here
listerWatcher Interface that provides the actual List() and Watch() calls
lastSyncResourceVersion Tracks the latest version seen — Watch resumes from this point after reconnect
resyncPeriod If > 0, Reflector periodically forces a full re-List to catch any missed events
backoffManager Exponential backoff when Watch connection drops

3. The ListerWatcher Interface

listerWatcher is an interface — Reflector doesn’t know or care which resource type it’s watching. The concrete implementation is injected per resource type.

type ListerWatcher interface {
    Lister
    Watcher
}

type Lister interface {
    List(options metav1.ListOptions) (runtime.Object, error)
}

type Watcher interface {
    Watch(options metav1.ListOptions) (watch.Interface, error)
}

For a Pod Informer, the concrete implementation looks like this:

// k8s.io/client-go/informers/core/v1/pods.go
func NewFilteredPodInformer(
    client kubernetes.Interface,
    namespace string,
    resyncPeriod time.Duration,
    indexers cache.Indexers,
    tweakListOptions internalinterfaces.TweakListOptionsFunc,
) cache.SharedIndexInformer {
    return cache.NewSharedIndexInformer(
        &cache.ListWatch{
            // List: calls the real Kubernetes API
            ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
                if tweakListOptions != nil {
                    tweakListOptions(&options)
                }
                return client.CoreV1().Pods(namespace).List(context.TODO(), options)
            },
            // Watch: opens a long-lived HTTP connection to the API Server
            WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
                if tweakListOptions != nil {
                    tweakListOptions(&options)
                }
                return client.CoreV1().Pods(namespace).Watch(context.TODO(), options)
            },
        },
        &corev1.Pod{},
        resyncPeriod,
        indexers,
    )
}

This is why Clientset must be created before SharedInformerFactory. The ListFunc and WatchFunc are closures over the Clientset — without it, Reflector has no way to talk to the API Server.

func NewKubeController(...) *KubeController {
    kc := &KubeController{clientset: clientset}

    // Clientset is passed in here — it flows down into every ListFunc/WatchFunc
    kc.factory = informers.NewSharedInformerFactory(clientset, defaultResync)

    kc.podInformer      = kc.factory.Core().V1().Pods()
    kc.podsLister       = kc.podInformer.Lister()
    kc.podsSynced       = kc.podInformer.Informer().HasSynced
    kc.deploymentInformer   = kc.factory.Apps().V1().Deployments()
    kc.deploymentsLister    = kc.deploymentInformer.Lister()
    kc.deploymentsSynced    = kc.deploymentInformer.Informer().HasSynced
    return kc
}

4. ListAndWatch: Phase 1 — Full List

The ListAndWatch method is the heart of Reflector. Phase 1 fetches the complete current state:

func (r *Reflector) ListAndWatch(stopCh <-chan struct{}) error {
    var resourceVersion string
    options := metav1.ListOptions{ResourceVersion: r.relistResourceVersion()}

    if err := func() error {
        // ① Fetch all resources (with pagination support)
        go func() {
            pager := pager.New(pager.SimplePageFunc(func(opts metav1.ListOptions) (runtime.Object, error) {
                return r.listerWatcher.List(opts)   // → calls ListFunc → Clientset.CoreV1().Pods().List()
            }))
            list, paginatedResult, err = pager.List(context.Background(), options)
            // handle expired ResourceVersion: retry with fresh version
            if isExpiredError(err) || isTooLargeResourceVersionError(err) {
                list, paginatedResult, err = pager.List(context.Background(),
                    metav1.ListOptions{ResourceVersion: r.relistResourceVersion()})
            }
            close(listCh)
        }()

        // wait for list to complete or stop signal
        select {
        case <-stopCh:   return nil
        case r := <-panicCh: panic(r)
        case <-listCh:
        }

        // ② Extract ResourceVersion from the list response
        listMetaInterface, _ := meta.ListAccessor(list)
        resourceVersion = listMetaInterface.GetResourceVersion()

        // ③ Convert list result into a slice of runtime.Object
        items, _ := meta.ExtractList(list)

        // ④ Store all objects + ResourceVersion into DeltaFIFO
        r.syncWith(items, resourceVersion)

        return nil
    }(); err != nil {
        return err
    }

    // ⑤ Record the latest ResourceVersion — Watch will resume from here
    r.setLastSyncResourceVersion(resourceVersion)
    ...
}

Phase 1 call chain:

ListAndWatch()
  │
  ├── ① r.listerWatcher.List()        → fetch all objects from API Server
  ├── ② listMetaInterface.GetResourceVersion()  → extract version stamp
  ├── ③ meta.ExtractList()            → convert to []runtime.Object
  ├── ④ r.syncWith()                  → write all objects into DeltaFIFO
  └── ⑤ r.setLastSyncResourceVersion() → save version for Watch resume point

5. ListAndWatch: Phase 2 — Continuous Watch

After the full List completes, Reflector enters an infinite loop watching for incremental changes:

    // Periodic resync goroutine (if resyncPeriod > 0)
    go func() {
        for {
            select {
            case <-resyncCh:
                if r.ShouldResync == nil || r.ShouldResync() {
                    r.store.Resync()   // force re-sync of Indexer from DeltaFIFO
                }
            case <-stopCh:
                return
            }
        }
    }()

    // Main Watch loop
    for {
        select {
        case <-stopCh:
            return nil
        default:
        }

        // Add randomized timeout to prevent thundering herd on reconnect
        timeoutSeconds := int64(minWatchTimeout.Seconds() * (rand.Float64() + 1.0))
        options = metav1.ListOptions{
            ResourceVersion:     resourceVersion,  // resume from last known version
            TimeoutSeconds:      &timeoutSeconds,
            AllowWatchBookmarks: true,
        }

        // ① Open a long-lived Watch connection to the API Server
        w, err := r.listerWatcher.Watch(options)
        if err != nil {
            if utilnet.IsConnectionRefused(err) {
                <-r.initConnBackoffManager.Backoff().C()  // backoff and retry
                continue
            }
            return err
        }

        // ② Process incoming events: write to DeltaFIFO + update ResourceVersion
        if err := r.watchHandler(start, w, &resourceVersion, resyncerrc, stopCh); err != nil {
            if !isExpiredError(err) {
                // log unexpected errors
            }
            return nil
        }
    }

Phase 2 call chain:

ListAndWatch() — Watch loop
  │
  ├── ① r.listerWatcher.Watch()    → open HTTP long-poll to API Server
  │                                   (passes ResourceVersion to resume from)
  └── ② r.watchHandler()           → for each incoming event:
                                       write object to DeltaFIFO
                                       update lastSyncResourceVersion

6. ResourceVersion: The Key to Reliable Watch

ResourceVersion is a monotonically increasing version stamp that etcd assigns to every resource object. It’s the mechanism that makes Watch reliable:

List response:  ResourceVersion = "1000"
                (all objects as of version 1000)
     │
     ▼
Watch request:  ResourceVersion = "1000"
                (give me all events AFTER version 1000)
     │
     ▼
API Server streams:
  - Pod "nginx" Updated  (RV=1001)
  - Pod "redis" Deleted  (RV=1002)
  - ...

If Watch connection drops:
     │
     ▼
Reflector reconnects with:  ResourceVersion = "1002"
(resumes exactly where it left off — no events missed, no duplicates)
Scenario ResourceVersion behavior
Fresh start "" or "0" — get latest snapshot
After List Set to the version returned by List response
After each Watch event Updated to the event’s ResourceVersion
Watch reconnect Resumes from lastSyncResourceVersion
Expired version (too old) Triggers a fresh List from scratch

7. Under the Hood: How Watch Works (HTTP Chunked Transfer)

The Watch connection is not a WebSocket or gRPC stream — it’s a plain HTTP/1.1 long-lived connection using Chunked Transfer Encoding.

Client (Reflector)                    API Server
     │                                     │
     │  GET /api/v1/pods?watch=true        │
     │  ResourceVersion=1000               │
     │ ──────────────────────────────────► │
     │                                     │
     │  HTTP/1.1 200 OK                    │
     │  Transfer-Encoding: chunked         │
     │ ◄────────────────────────────────── │
     │                                     │
     │  chunk: {"type":"ADDED","object":…} │
     │ ◄────────────────────────────────── │
     │                                     │
     │  chunk: {"type":"MODIFIED","object":…}
     │ ◄────────────────────────────────── │
     │                                     │
     │  (connection stays open…)           │
     │  (new chunks arrive as events occur)│

Why Chunked Transfer Encoding?

In standard HTTP, the Content-Length header tells the client how many bytes to expect. But for a Watch stream, the server doesn’t know in advance how many events will occur — the stream is open-ended.

Chunked Transfer Encoding solves this:

  • The response body is split into variable-size chunks
  • Each chunk is prefixed with its size in hex
  • The server sends chunks as events arrive — no pre-declared content length needed
  • A zero-length chunk signals the end of the stream
HTTP/1.1 200 OK
Transfer-Encoding: chunked

1arn                          ← chunk size (hex)
{"type":"ADDED","object":…}rn ← chunk data
rn
2frn
{"type":"MODIFIED","object":…}rn
rn
0rn                           ← end of stream
rn

This is only available in HTTP/1.1 and later. It’s what makes Kubernetes Watch efficient — a single persistent connection replaces thousands of polling requests.

8. Summary

Reflector.Run()
     │
     └── ListAndWatch()
           │
           ├── Phase 1: LIST
           │     ├── listerWatcher.List()          → full snapshot from API Server
           │     ├── GetResourceVersion()           → stamp the snapshot version
           │     ├── meta.ExtractList()             → parse into objects
           │     └── syncWith() → DeltaFIFO         → seed the local cache
           │
           └── Phase 2: WATCH (infinite loop)
                 ├── listerWatcher.Watch(RV)        → HTTP chunked long-poll
                 ├── watchHandler() → DeltaFIFO     → stream events to queue
                 └── on disconnect: backoff + retry
                     on expired RV: re-List from scratch
Concept Detail
ListerWatcher Interface injected per resource type; backed by Clientset API calls
ResourceVersion etcd version stamp; enables Watch to resume without missing events
DeltaFIFO The downstream queue; Reflector is its sole producer
Chunked Transfer HTTP/1.1 mechanism that keeps the Watch connection open indefinitely
Backoff Exponential retry when Watch connection drops or API Server is unavailable

Reflector is the bridge between the Kubernetes API Server and the local Informer cache. Once you understand its List → syncWith → Watch → watchHandler pipeline, the reliability guarantees of the entire Informer framework become clear.

Next in this series: DeltaFIFO: The Event Queue Behind Informer (Part 3)

Follow the series for more deep dives into Kubernetes development.

Why Your JavaScript Is Too Big (And How to Fix It in 2 Minutes)

Every byte of JavaScript your site ships has to be downloaded, parsed, and executed before your page becomes interactive. For many sites, JavaScript is the single biggest performance bottleneck — and most of it is entirely avoidable bloat.

Here’s the thing: the JavaScript you write for development and the JavaScript you serve to users should not be the same file.

What is JavaScript minification?

Minification removes everything from your JS that the browser doesn’t need:

  • Whitespace — newlines, tabs, extra spaces
  • Comments — helpful for developers, invisible to the browser
  • Long variable names — userAccountBalance becomes a
  • Redundant syntax — return true; might become return!0

The result is functionally identical code that can be 30–70% smaller.

Example:

Before:

// Calculate the total price with tax
function calculateTotalPrice(basePrice, taxRate) {
    const taxAmount = basePrice * (taxRate / 100);
    const totalPrice = basePrice + taxAmount;
    return totalPrice;
}

After minification:

function calculateTotalPrice(a,t){return a+a*(t/100)}

Same behaviour. 70% smaller.

Why it matters

JavaScript is render-blocking by default. While the browser is parsing your JS, it pauses everything else.

A real-world example: a 200KB unminified script file might minify down to 60KB. That’s 140KB less to transfer over a mobile connection — roughly 0.5–1 second faster on a 4G connection.

At Google’s Core Web Vitals thresholds, that’s the difference between “good” and “needs improvement.”

When to minify

Minify for production. Never serve minified files directly to development — you want readable code with source maps for debugging.

Most build tools (webpack, Vite, Rollup, Parcel) handle this automatically with Terser under the hood. But if you’re working on a simple project without a build step, you need to minify manually.

How to minify JavaScript without a build tool

For quick jobs — a standalone script, a bookmarklet, a snippet for a client — use an online minifier.

The important thing is to use a modern engine. Many older online tools use UglifyJS, which doesn’t handle ES6+ (async/await, arrow functions, optional chaining). Terser is the current standard — it’s what webpack uses, handles modern syntax, and produces smaller output than older tools.

→ Try it: SnappyTools CSS Minifier + Beautifier (and a JS minifier is coming soon)

Should you also minify CSS and HTML?

Yes — but the gains are different:

File type Typical size reduction
JavaScript 30–70%
CSS 20–40%
HTML 10–20%

JS has the highest payoff because minifiers can also mangle variable names — something CSS/HTML don’t support.

The “beautify” direction: unminifying code

Minification is reversible. If you receive minified code and need to read it (debugging a third-party library, auditing a script), a JavaScript beautifier will re-indent and format it back to readable code.

This is the reverse of minification: add whitespace, newlines, and consistent indentation so you can actually read it.

A quick checklist

Before you deploy any JavaScript-heavy page:

  • [ ] Is your main bundle minified?
  • [ ] Are you serving gzip or Brotli compressed responses? (Your CDN handles this — check.)
  • [ ] Are you code-splitting? (Only load what the current page needs.)
  • [ ] Are there any unused libraries? (Run a quick audit with Chrome DevTools Coverage tab.)

Minification is the easiest win. It takes two minutes and requires no architectural changes.

SnappyTools builds free, fast, browser-based tools for developers. No signup, no data uploaded.

I Took Cursor Agents Out of the IDE and It Got Weirdly Powerful 🤯

Proof

Cursor recently released their cursor agents SDK to the public, and it’s quietly powering many teams to:

  • invoke agents directly from CI/CD pipelines,
  • create automations for end-to-end workflows,
  • and embedding agents into core products.

Basically, the SDK lets developers deploy agents without overthinking building and maintaining the entire agent stack

in this blog I give a glimpse of what is possible with the cursor agents SDK and how to overcome the restrictions of limited tool access.

Let’s start with a brief overview of cursor agents.

A Primer On Cursor Agents SDK

Building an agent from scratch is a massive headache. Cursor SDK skips the
plumbing.

Before the SDK, Cursor was strictly an interactive IDE, but its newly released SDK turns that
agentic power into headless infrastructure.

It uses the same harness that powers the desktop app, meaning you get IDE-grade code generation programmatically.

Harness: context engine, workspace management, and routing

In fact, the cursor harness matters more than the model. Endor Labs benched
GPT-5.5 natively at 61.5% functional correctness. Then, dropping the same model into Cursor’s harness, it scored 87.2%.

Proof

Cursor spent years tuning the context management and tool dispatch; the SDK hands you that refined engine.

Here is the spec list for those who care:

Spec / Feature Technical Detail
The Harness Built-in codebase indexing, semantic search, & instant grep.
Execution Cloud (sandboxed VMs with durable state), Self-Hosted, Local.
Models Agnostic. Uses composer-2/composer-3 (default), Claude, or OpenAI.
Integrations Deep MCP support, auto-loaded .cursor/skills/, & hooks.
Subagents Main agent can spawn subagents with distinct prompts/models.

This means we have a perfect stack to build our agent. Let’s get started with the setup.

How to Set Up Cursor Agents SDK

You can install it using npm to get started, then use Cursor’s native cursor SDK skill for guidance.

So open your terminal and type:

npm install @cursor/sdk

Output

Once done, install the cursor skill for guiding the cursor / Claude Code. In the same terminal, run:

npm add skills /cursor-sdk

But there is a catch: by default, the Cursor SDK agents ship with a few default tools + MCP support.

However, this means connecting to or configuring multiple MCP servers is such a hassle for a production-grade product.

So let’s automate it with Composio as the orchestrator, connect and configure it once, and you get secure access to 1000+ tools with optimized tool calls and context.

Building a Production Agent With Composio MCP + Cursor Agent SDK

Why Composio?

Composio offers a single MCP server you can plug into with the Cursor SDK to access 1000+ apps instantly.

When you’re building an agent that requires interacting with external applications, let’s say a Sales agent with Gong, HubSpot, Salesforce connectors, etc., you’d spend weeks on partnerships and integrations. Composio completely removes this friction, so you build what matters

So, let’s explore one example of a GitHub agent that pulls the repository, analyzes it, creates a dev branch, performs automatic refactoring, pushes the code, and opens a PR for review – a simple but powerful use case.

Let’s begin!

Setup Workspace

Head to the terminal and run the following commands

mkdir cursor-agent
cd cursor-agent
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init

The command will create a new project named cursor_agent , switch to the folder, initialize a blank npm project (ensure package.json gets created), install typescript support for development, and initialize the typescript compiler and linter.

Since our repository is set up, let’s configure environment variables.

Set up Environment Variables

Add a .env file to keep secrets secure and add the following values:

CURSOR_API_KEY=your-cursor-api-key
COMPOSIO_API_KEY=your-composio-api-key

You can get the CURSOR_API_KEY by going to the Cursor integration page and creating a new API key.

Step 1

For COMPOSIO_API_KEY , visit the Composio Dashboard (Platform), log in/sign up to the account, and head to the default project (or create one) → profile→ API KEY.

Step 2

Click “Create One”, it creates one, copy and then paste it into .env . Make sure it starts with ak_ .

Step 3

Time to write the code.

The Code

Now, create a new index.ts file and paste the following code.

// imports
import "dotenv/config";
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import type { SDKAgent } from "@cursor/sdk";
import {
  DEFAULT_USER_ID,
  disposeAllAgents,
  getComposioApiKey,
  getOrCreateRuntime,
  requireEnv,
} from "./runtime.ts";

// log tool calls to stdout
function logToolCall(
  name: string,
  args: unknown,
  status: "running" | "completed" | "error",
): void {
  if (status === "running") {
    console.log(`n[tool: ${name}] ${JSON.stringify(args)}`);
  } else if (status === "completed") {
    console.log(`[tool: ${name}] done`);
  }
}

// stream assistant text and tool events - act as streaming handler
async function runAgentChatStreaming(agent: SDKAgent, message: string): Promise<void> {
  const run = await agent.send(message);

  process.stdout.write("nagent > ");
  for await (const event of run.stream()) {
    if (event.type === "assistant") {
      for (const block of event.message.content) {
        if (block.type === "text") process.stdout.write(block.text);
      }
    } else if (event.type === "tool_call") {
      logToolCall(event.name, event.args, event.status);
    }
  }

  const result = await run.wait();
  if (result.status === "error") {
    throw new Error(`Agent run failed (${result.id})`);
  }
  process.stdout.write("nn");
}

// runs the chat loop, adds a readline interface and disposes agents on exit
async function main(): Promise<void> {
  requireEnv("CURSOR_API_KEY");
  getComposioApiKey();

  console.log("Setting up Composio session...");
  const runtime = await getOrCreateRuntime(DEFAULT_USER_ID);
  console.log(
    `Ready (user: ${runtime.userId}, session: ${runtime.sessionId}, tools: ${runtime.toolsCount}).`,
  );
  console.log("Type a message, or 'exit' to quit.n");

  const rl = readline.createInterface({ input, output });

  try {
    while (true) {
      const userInput = (await rl.question("you > ")).trim();
      if (!userInput) continue;
      if (userInput === "exit" || userInput === "quit") break;

      await runAgentChatStreaming(runtime.agent, userInput);
    }
  } finally {
    rl.close();
    await disposeAllAgents();
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Let’s look at the code:

  • We start by loading dependencies from .env, readline, and shared helpers from runtime.ts (getOrCreateRuntime, API key checks, and cleanup)
  • For a sanity check, we validate CURSOR_API_KEY and COMPOSIO_API_KEY before the chat loop starts (Composio is also checked when runtime.ts loads).
  • Next, we create a Composio session for the default user and spin up a Cursor agent on composer-2 with the Composio MCP server for tools (handled in runtime.ts).
  • We keep the same agent in the loop for every message, so tools and sessions stay wired throughout the CLI session.
  • We run a terminal chat loop with readline: read input → send to agent → stream assistant text and tool status → repeat until exit or quit.
  • While streaming, we log tool calls on running and completed, and throw if the agent run ends in error.
  • On exit, we close readline, dispose of agents via runtime.ts, and log any failure from main() before exiting with code 1.

If you encounter an issue, refer to the codebase in the project’s GitHub repo. Feel free to clone it, tweak it to your liking, or use it as it is.

And that’s it, all set, time to run the agent!

Run the agent

To run the agent in the terminal and at the project root, type:

npx ts-node index.ts

and an interactive chat opens up where you can prompt it step by step / direct it to do the entire task at once. Here is a demo of me using it in chat mode (step by step)

As you can see, the cursor agent acts as the brain, calling the Composio GitHub tools to clone the repo, check for changes, create a dev branch, refactor the code, commit to the dev branch, and finally open a PR.

Usually, this takes many human hours, but with a cursor-agent, it takes only 5 minutes.

Also, with the new update, you need to add a GitHub token to use GitHub with the cursor/agent. Using Composio fixes this need as well.

Note: For simplicity I have used terminal , but you can add a ui layer on top of the agent.

With this, we have reached the end of the article. Here is my closing note.

Final Thoughts

In this article, we learned how to use the Cursor SDK and Composio MCP to build a GitHub chatbot while keeping it optimized for asynchronous operations and following best practices, such as masking sensitive variables.

But this is just the tip of the iceberg with what’s possible with the cursor agent SDK. Feel free to experiment and build your own assistant/chatbot. You can explore more in the cursor-cookbook.

At the end of the day, building agents / multi-agent systems is not hard; planning and orchestrating them to work efficiently, collaboratively, and securely is. In fact, it’s the next valuable skill in 2026.

So what are you waiting for? Get started with Cursor Agents while Composio MCP handles the tooling layer.

client-go Deep Dive: Informer — The Core of Kubernetes Controller Development

If you’ve ever built a Kubernetes controller — or wondered how kube-controller-manager keeps thousands of resources in sync — the answer is Informer. It’s the single most important component in client-go, and mastering it is the foundation of all Kubernetes development.

1. The Problem Informer Solves

Kubernetes components need to constantly watch and query cluster resources. Take the Deployment controller: it must track every Deployment and its ReplicaSets in real time, continuously reconciling actual state toward desired state.

Doing this naively creates serious problems:

Problem Impact
Polling the API Server repeatedly Crushes API Server and etcd under query load
No middleware between components Must guarantee message reliability, ordering, and real-time delivery over raw HTTP
Multiple controllers watching the same resource Duplicate List/Watch calls, redundant serialization/deserialization

Informer solves all three — with a local cache, a shared watch connection, and an event subscription model.

2. How Informer Works: List/Watch

The core mechanism is List/Watch:

Informer starts
     │
     ▼
LIST  ── fetch ALL resources of interest from API Server
     │    store full snapshot in local cache (Indexer)
     ▼
WATCH ── open a long-lived HTTP connection to API Server
     │    receive incremental change events (Add/Update/Delete)
     ▼
Update local cache on every event
(cache stays in sync with etcd without polling)

Benefits:

  • Controllers query the local cache (Indexer) — extremely fast, zero API Server load
  • A single Watch connection replaces thousands of polling requests
  • Cache is always consistent with etcd state

3. The Informer Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        Informer Framework                       │
│                                                                 │
│  API Server                                                     │
│      │  List / Watch                                            │
│      ▼                                                          │
│  ┌──────────┐    events    ┌─────────────┐                      │
│  │ Reflector│ ──────────► │  DeltaFIFO  │                      │
│  │(List/Watch)│           │  (event queue)│                     │
│  └──────────┘             └──────┬──────┘                      │
│                                  │ Pop                          │
│                                  ▼                              │
│                          ┌───────────────┐                      │
│                          │   Controller  │ (Informer internal)  │
│                          │ processLoop() │                      │
│                          └──────┬────────┘                      │
│                    ┌────────────┴──────────────┐                │
│                    ▼                           ▼                │
│             ┌────────────┐           ┌──────────────────┐       │
│             │  Indexer   │           │  ShareInformer   │       │
│             │(local cache│           │  distribute()    │       │
│             │ ThreadSafe)│           └────────┬─────────┘       │
│             └────────────┘                    │                 │
│                    ▲                          ▼                 │
│                    │ Lister           AddEventHandler           │
│             ┌──────┴──────┐          (user callbacks)          │
│             │ Your        │                  │                  │
│             │ Controller  │◄─── WorkQueue ───┘                  │
│             └─────────────┘                                     │
└─────────────────────────────────────────────────────────────────┘

4. The Complete Data Flow (Step by Step)

① Controller starts Reflector
   Reflector calls List → fetches ALL resources → stores in DeltaFIFO
   Reflector calls Watch → streams future change events into DeltaFIFO

② Controller's processLoop() calls DeltaFIFO.Pop()
   Pops events one by one → passes to HandleDeltas callback

③ HandleDeltas: if event type is Add / Update / Delete
   → stores/updates/removes object in Indexer (local cache)
   → Indexer is backed by ThreadSafeMap (concurrent-safe in-memory store)

④ HandleDeltas also calls distribute()
   → fans out event to all registered ShareInformer listeners

⑤ Your controller registers via informer.AddEventHandler()
   On event: adds resource key to WorkQueue

⑥ Your controller's worker goroutine calls WorkQueue.Get()
   → runs reconciliation logic
   → if error: re-enqueues with rate limiting for retry

⚠️ Important distinction: The “Controller” in steps ①② is an Informer-internal component (k8s.io/client-go/tools/cache/controller.go) that manages the Informer’s startup flow. The “controller” in steps ⑤⑥ is your application controller that reconciles resource state.

5. Best Practice: Clientset + Informer

The recommended pattern in Kubernetes development:

┌─────────────────────────────────────────────────┐
│  Use Clientset for:  Create / Update / Delete   │
│  Use Informer for:   Read / List / Watch        │
└─────────────────────────────────────────────────┘

Never use clientset.CoreV1().Pods().List() in a hot path — always query the Informer’s local cache via Lister.

6. Code Walkthrough

Step 1 — Build a Clientset from kubeconfig

func GetKubeClient(cfgpath string) (*kubernetes.Clientset, *restclient.Config) {
    kubeconfig, err := clientcmd.BuildConfigFromFlags("", cfgpath)
    if err != nil {
        panic(err)
    }
    clientset, err := kubernetes.NewForConfig(kubeconfig)
    if err != nil {
        panic(err)
    }
    return clientset, kubeconfig
}

Step 2 — Define the Controller struct

type KubeController struct {
    kubeConfig  *restclient.Config
    status      int32
    clientset   *kubernetes.Clientset
    factory     informers.SharedInformerFactory

    // One Informer + Lister + HasSynced per resource type
    podInformer coreinformers.PodInformer
    podsLister  corelisters.PodLister
    podsSynced  cache.InformerSynced
    // ... deployments, services, etc.
}

Step 3 — Initialize with SharedInformerFactory

func NewKubeController(
    kubeConfig *restclient.Config,
    clientset *kubernetes.Clientset,
    defaultResync time.Duration,
) *KubeController {
    kc := &KubeController{kubeConfig: kubeConfig, clientset: clientset}

    // SharedInformerFactory ensures all informers for the same resource
    // share ONE Reflector — no duplicate List/Watch calls
    // defaultResync=0: List once at startup, no periodic re-sync
    kc.factory = informers.NewSharedInformerFactory(clientset, defaultResync)

    kc.podInformer = kc.factory.Core().V1().Pods()
    kc.podsLister  = kc.podInformer.Lister()
    kc.podsSynced  = kc.podInformer.Informer().HasSynced

    return kc
}

Step 4 — Start the factory and wait for cache sync

func (kc *KubeController) Run(stopCh chan struct{}) {
    defer utilruntime.HandleCrash()

    // Start all informers registered with this factory
    kc.factory.Start(stopCh)

    // Block until the initial List is complete and cache is populated
    if !cache.WaitForCacheSync(stopCh,
        kc.podsSynced,
        kc.deploymentsSynced,
        kc.servicesSynced,
    ) {
        utilruntime.HandleError(fmt.Errorf("timed out waiting for caches to sync"))
        return
    }

    kc.status = 1 // signal: cache is ready, safe to query
    <-stopCh
}

Step 5 — Query the cache via Lister (never hit the API Server)

func main() {
    clientset, kubeConfig := GetKubeClient("conf/config")
    kc := NewKubeController(kubeConfig, clientset, time.Second*3000)

    stopCh := make(chan struct{})
    go kc.Run(stopCh)

    // Wait for cache to be ready before querying
    for kc.status != 1 {
        time.Sleep(time.Second)
    }

    // Query from local cache — fast, no API Server call
    pod, err := kc.podsLister.Pods("tech-daily").Get("hello-omega-deployment-7d8ff89d87-25kb4")
    if err != nil {
        log.Fatalf("get pod err: %s", err)
    }
    log.Printf("pod is running on node: %s", pod.Spec.NodeName)
}

7. SharedInformer: Why It Matters

If you instantiate multiple Informers for the same resource type, each one creates its own Reflector and makes its own List/Watch call — wasting resources and adding API Server load.

SharedInformer solves this by maintaining a single Reflector per resource type, shared across all consumers:

// Internal structure of SharedInformerFactory
type sharedInformerFactory struct {
    client           kubernetes.Interface
    lock             sync.Mutex
    defaultResync    time.Duration
    customResync     map[reflect.Type]time.Duration

    // Key insight: one SharedIndexInformer per resource type
    // All calls to factory.Core().V1().Pods() return the SAME informer
    informers        map[reflect.Type]cache.SharedIndexInformer
    startedInformers map[reflect.Type]bool
}
Without SharedInformer:
Controller A → Reflector A → List/Watch Pods
Controller B → Reflector B → List/Watch Pods   ← duplicate!
Controller C → Reflector C → List/Watch Pods   ← duplicate!

With SharedInformerFactory:
Controller A ──┐
Controller B ──┼──► Single Reflector → List/Watch Pods once  ✅
Controller C ──┘

8. Summary

Component Role
Reflector Calls List/Watch against API Server; feeds events into DeltaFIFO
DeltaFIFO FIFO queue storing resource change events with their delta type
Controller (internal) Drives the Informer loop; pops from DeltaFIFO via processLoop
HandleDeltas Routes events to Indexer (for storage) and ShareInformer (for callbacks)
Indexer Thread-safe in-memory cache; backed by ThreadSafeMap
Lister Read interface over Indexer — fast local queries, no API Server calls
ShareInformer Fan-out mechanism; delivers events to all AddEventHandler subscribers
WorkQueue Rate-limited, deduplicating queue; bridges event callbacks and controller workers

Informer is the backbone of every Kubernetes controller. Once you understand its List/Watch → DeltaFIFO → Indexer → WorkQueue pipeline, the architecture of kube-controller-manager, custom controllers, and Operators all become clear.

Next in this series: Reflector: How client-go Syncs with the API Server (Part 2)

Follow the series for more deep dives into Kubernetes development.

Tech Lost 150K Jobs in 2026. Data Engineering Gained 414%.

I got laid off once. Not from a data engineering role; from an analytics-adjacent contracting gig that evaporated when budgets got cut. I spent exactly one week feeling sorry for myself, then I started grinding. That was years ago. Since then I’ve watched three separate waves of “tech is over” panic, sat through two recessions worth of hiring freezes, and somehow ended up at staff level building pipelines at companies you’ve definitely used. The pattern is always the same: broad panic, selective survival, and a very small number of people who read the room correctly and came out the other side making more money than before.

2026 is that pattern again, except the signal is louder than it’s ever been.

150,000 Jobs Gone. Data Engineering Didn’t Flinch.

The numbers are ugly. Over 150,000 tech jobs cut across 500+ companies in 2026. Q1 alone saw 52,050 layoffs, a 40% jump over Q1 2025 and the worst first quarter since 2023. That’s roughly 973 people per day losing their jobs. If you’re in tech and you don’t know someone who got hit, you’re not paying attention.

But here’s the part nobody’s talking about at happy hour: data engineering is projected to grow 414% through 2030. Over 150,000 data engineers are currently employed, with 20,000+ new jobs created in the past year alone. The global data engineering services market hit $105 billion in 2026 and is growing at 15% annually.

These two facts exist simultaneously. Massive contraction and massive expansion, in the same industry, at the same time.

The displacement isn’t random. Data analytics postings dropped 15.2% year over year. Broader tech postings fell 36%. But data engineering grew. Not “held steady.” Grew. This isn’t the whole boat rising; it’s one lifeboat pulling away while the ship lists.

40% of data teams expanded headcount in 2025 (up from 14% the year before), even as 41% reported negative budget impacts from economic pressures. They’re not adding headcount for fun. They’re replacing other roles with engineers who build infrastructure.

That’s the substitution nobody wants to name. Companies aren’t growing data teams out of optimism. They’re swapping analysts and BI developers for engineers who can build the plumbing that AI systems need to function. It’s not growth; it’s triage.

The GM Playbook: Fire IT, Hire Data Engineers

If you want to see the pattern in action, look at GM. In May 2026, they laid off 600 salaried IT workers, roughly 10% of their IT department. Identity access management, platform security, software engineering teams. Gone.

Then they immediately opened positions for data engineering, analytics, AI-native development, and cloud-based engineering.

This isn’t a contradiction. It’s a skills swap. GM didn’t cut costs and call it a day. They cut roles they decided AI could handle or that weren’t generating direct value, then reinvested in the roles they believe are load-bearing for the next five years. Data engineers made that list. Traditional IT didn’t.

And GM isn’t unique. The same pattern is playing out across the industry. Companies are discovering that 88% of their agentic AI pilots fail to reach production, not because the models are bad, but because the data infrastructure underneath them is a mess. Disconnected metadata catalogs, fragmented pipelines, schemas that nobody documented, cost optimization that nobody owns. Every failed AI pilot is a job posting for a data engineer.

The quote I keep seeing in industry reports: “Most teams are hiring data engineers to rebuild the plumbing: cleaner pipelines, faster ingestion, better monitoring, and datasets that can be trusted in production.” That’s the job. It’s always been the job. Now there’s a $105 billion market saying it out loud.

What AI Actually Automates (and What It Can’t Touch)

Here’s where most people get the career math wrong. They hear “AI is automating data engineering” and assume it’s a uniform threat. It’s not. The automation is extremely specific, and knowing which side of the line your skills sit on is the difference between a 414% growth curve and a pink slip.

The numbers on automation rates tell the story clearly. Data quality checks: 70% automatable. ETL pipeline generation: 65%. Database optimization: 58%. Data warehouse and lake architecture: 38%.

See the pattern? The further you move from “write this query” toward “design this system,” the less AI can do. Boilerplate SQL generation? Gone. Figuring out why your pipeline silently dropped 2 million rows last Tuesday because an upstream team changed a schema without telling anyone? That’s a human problem. It requires business context, institutional knowledge, and the ability to yell at the right Slack channel at 2am.

Python appears in 70% of 2026 data engineer postings. SQL dropped to 69%, down from 79% in 2025. That’s not a typo. SQL, the language that defined data work for decades, is now less common in job postings than Python. The shift tells you exactly what companies are buying: less query-writing, more infrastructure architecture, more orchestration, more systems thinking.

Gartner projects AI will reduce manual intervention in data engineering by 60%. Which sounds terrifying until you realize the 40% that’s left is all the hard stuff. Capacity planning across regions. Schema migrations that touch compliance rules. Cost optimization decisions where the CFO doesn’t accept “AI said so” as justification. The comfortable middle of data engineering is getting automated. What’s left is the stuff that actually requires judgment.

I’ve been through three waves of “data engineering is getting automated away.” Still here. Still employed. Still debugging the same categories of problems. The tools change every 18 months. Schema drift, late-arriving data, upstream teams breaking contracts without telling you; these are eternal.

The Two-Tier Market Is Already Here

The split isn’t coming. It’s here. And it’s creating two very different career trajectories.

Tier one: Entry-level ETL work, boilerplate transformations, basic pipeline assembly. This is automating at 65-70%. If your daily work is writing the same dbt models and Airflow DAGs without understanding why the pipeline exists or what business decision it feeds, you’re on the wrong side of this line.

Tier two: Architecture, cost optimization, governance, production debugging, ML infrastructure. This is growing. Fast. Data engineers now spend 37% of their work hours on AI-related projects, up from 19% in 2023, projected to hit 61% by 2027. The role isn’t shrinking; it’s shifting upward.

And the hiring market reflects this perfectly. 45% of data engineering postings now contain AI-related terms. CI/CD and DevOps appear in one out of every six postings. 26% of postings skip education requirements entirely; they don’t care about your degree, they care about your production code samples.

Here’s what that means for your hiring prospects. The companies with unfilled data engineering reqs sitting for 12-18 months aren’t struggling because there aren’t enough data engineers. They’re struggling because there aren’t enough data engineers with the right skills. It’s a mismatch, not a shortage.

The most underrated part of this: analytics engineers earn a median of $189,000 versus data engineers at $131,000, yet analytics engineering isn’t projected for the same growth. Companies are overpaying for the title they think they need while undertesting for the skills they actually need. I’ve been on hiring panels where we tested pipeline architecture for an analytics engineer role and business context for a data engineering role. Backwards. Every time.

What the Survivors Are Doing Differently

The people who come out of this cycle making $148,000 to $186,000 (the San Francisco range for data engineers right now) aren’t the ones who learned one more tool. They’re the ones who understood which problems compound.

Concepts transfer across tools; tool knowledge doesn’t transfer across concepts. I’ve been saying this for years and it’s never been more true. The engineer who understands data modeling, query optimization, and why things break will learn whatever orchestrator the company uses in a week. The engineer who memorized Airflow’s API but can’t explain why a star schema might not be the right choice anymore (hint: the economics killed it) is going to have a harder time.

The skill stack that’s actually getting people hired: Python and SQL as baseline (still non-negotiable, even as SQL’s dominance fades). Spark at 38.7% of postings. Cloud fluency, with AWS at 32% market share. And increasingly, AI literacy; not “build a transformer from scratch” but “understand how your pipelines feed ML systems and how to optimize that relationship.”

The real career insight hiding in all of this data: production infrastructure beats research. Every time. Data engineers earning $130K-$180K while data scientists struggle for roles reflects a truth the industry doesn’t like admitting. The CFO cares about the pipeline that feeds the board deck, not the model that got 2% better accuracy on a benchmark nobody uses.

Junior engineers worry about which tool to learn. Senior engineers worry about which problems to solve. Staff engineers worry about which problems to prevent.

That hierarchy maps directly onto the automation curve. Tools get automated. Problems don’t. Prevention definitely doesn’t.

I’ve watched people with 10 years of experience get downleveled because they couldn’t articulate system design decisions under pressure. I’ve also watched people with non-traditional backgrounds land staff roles because they could explain exactly why their pipeline was designed the way it was and what it would cost to change it. The interview is a different skill than the job, but both skills reward the same thing: understanding the “why” behind the architecture, not just the “how” of the implementation.

The 150,000 jobs that vanished in 2026 aren’t coming back. The 414% growth curve in data engineering isn’t slowing down. The gap between those two numbers is the entire story of tech employment right now. The question is just which side of that gap you’re standing on.

So: what’s the one skill in your current stack that you’re most worried AI is about to eat? And what are you replacing it with?

How I Made My Android App Discoverable on 4 LLMs in 24 Hours (llms.txt, IndexNow, JSON-LD, the Bing Cycle)

The setup

Last Tuesday evening I opened the Supabase dashboard and ran a few queries on my product’s traffic.

  • ChatGPT referrers over the past month: 3 visitors
  • Bing site:tamsiv.com operator: 0 results
  • Common Crawl captures for the domain: 0
  • Google referrers per week: ~10 (stable)

So I had one fragile channel (ChatGPT was finding the site through OAI-SearchBot crawling directly + dev.to backlinks), and everything else was empty.

But ChatGPT isn’t all the LLMs. Each major assistant has its own search backend, and optimizing for one doesn’t transfer to the others.

The map I hadn’t drawn

LLM Search backend TAMSIV status
ChatGPT search, Copilot Bing invisible
Claude (Anthropic) Brave Search + own crawler P1 on long-tail queries
Gemini, AI Overview Google indexed, ~10 referrers/week
Perplexity Bing + DuckDuckGo + own crawler unknown
Grok X/Twitter + own absent (no X presence)
You.com, Kagi mostly Bing unknown
DeepSeek, Mistral, Llama (open-source) Common Crawl (training corpus) zero captures

Out of ten major LLMs, I had a real presence on only two (Google and Brave). The rest were either invisible or completely missing.

The technical stack I shipped in one session

1. llms.txt and ai.txt at the root

llmstxt.org proposes a manifest format at the root of a site, designed to be parsed by language models. One paragraph of identity, then categorized links to the most important pages. I added it alongside ai.txt (the Spawning standard, explicit opt-in for AI training).

# TAMSIV

> TAMSIV is an AI voice assistant for Android that turns spoken commands
> into organized tasks, memos, and shared checklists. ...

## Core pages

- [Homepage (English)](https://www.tamsiv.com/en): Product overview
- [Pricing](https://www.tamsiv.com/en#pricing): Free, Pro, Team
- [FAQ](https://www.tamsiv.com/en/guide/faq): Questions
- [Blog](https://www.tamsiv.com/en/blog): 60+ articles

## Best technical articles

- [Voice pipeline with Deepgram and WebSocket](https://www.tamsiv.com/en/blog/deepgram-voice-pipeline-websocket)
- ...

2. Extended JSON-LD across the site

The landing page now ships four structured-data blocks (down from three minimal ones), and each blog article ships two enriched ones.

Landing:

const softwareApplicationLd = {
  '@context': 'https://schema.org',
  '@type': 'SoftwareApplication',
  name: 'TAMSIV',
  operatingSystem: 'Android',
  applicationCategory: 'ProductivityApplication',
  applicationSubCategory: 'TaskManagement',
  inLanguage: ['fr', 'en', 'de', 'es', 'it', 'pt'],
  featureList: [
    'Voice-first task and memo creation',
    'Unlimited hierarchical folders (up to 6 levels)',
    'Real-time multi-user collaboration',
    // 7 more...
  ],
  offers: [
    { '@type': 'Offer', name: 'Free', price: '0', priceCurrency: 'EUR' },
    { '@type': 'Offer', name: 'Pro', price: '4.99', priceCurrency: 'EUR' },
    { '@type': 'Offer', name: 'Team', price: '8.99', priceCurrency: 'EUR' },
  ],
};

For each blog article, a FAQPage is auto-extracted from the HTML at build time. The trick: parse the <h2>FAQ</h2> heading and the following <h3>question</h3><p>answer</p> pairs.

3. IndexNow API endpoint

IndexNow is a protocol Microsoft and Yandex co-published to let site owners push URLs to crawlers instead of waiting for them. I exposed a small Next.js route handler that POSTs to api.indexnow.org, bing.com/IndexNow, and yandex.com/indexnow in parallel.

4. “In short” answer blocks on existing articles

The four articles already cited by ChatGPT got a 50-65 word answer-first block at the top, in all six site languages (24 files prepended). The format is calibrated so LLMs can extract the passage and quote it directly without scrolling 500 words.

5. First competitor comparison page

/[locale]/vs/todoist shipped as a dynamic Next.js route fed by a vs-data.ts config. Full JSON-LD stack: two SoftwareApplication entries (TAMSIV and the competitor), one ItemList, one FAQPage, one BreadcrumbList. LLMs love honest comparisons; SourceForge builds its own comparison pages from these.

Bing Webmaster + IndexNow batch

Two more steps that I could automate:

Bing Webmaster via Google OAuth. From the new site flow, the “Import from Google Search Console” option pulls verified sites instantly. Two sites imported in under a minute, sitemaps included, no DNS or meta tag verification needed.

Batch IndexNow for the entire sitemap. I extracted 498 URLs from sitemap.xml, split them into chunks of 50, and pushed each chunk to all three endpoints in parallel:

curl -s https://www.tamsiv.com/sitemap.xml 
  | grep -oE '<loc>[^<]+</loc>' 
  | sed -E 's|<loc>(.*)</loc>|1|' 
  > sitemap-urls.txt

Result: 30 chunks pushed, 30 OK across the three endpoints. Total push time: about 50 seconds.

The day-after test, four LLMs probed live

Perplexity. Asked “What is TAMSIV voice task manager Android”. Got a complete answer with 10+ sources cited: four pages on tamsiv.com, the Play Store, a Slashdot listing I didn’t know existed, the YouTube demo video, two dev.to articles, and a third-party LinkedIn post that reposted some info. Rich, accurate, attributed correctly.

Gemini. Asked the same. Got three sources: Play Store, SourceForge comparison page I didn’t know existed either, and one of our blog articles. The features section was correctly populated (NLP, multi-task extraction, real-time collab, gamification, six languages, hierarchical folders). One amusing detail: Gemini credited the app to a different indie hacker by name. The hallucination is real but it doesn’t matter to me. What matters is the app being found, not who built it.

Claude via WebSearch. Ranks tamsiv.com first on “best voice task manager Android 2026”.

Bing. site:tamsiv.com still returned zero (operator indexing takes days for new domains), but the real traffic data showed two first human Bing visitors landing on the site within 24 hours of the IndexNow push. A UK visitor on the supabase-egress article, a French visitor on the AI conversation-history article. Source that was zero the day before.

The directories surprise

Slashdot and SourceForge both had auto-generated TAMSIV listings, scraped from our Play Store description. The descriptions were precise (price, languages, features, even our “750+ commits solo developer” tagline). I never submitted those.

But SourceForge had imported some defaults that don’t match reality: it claims TAMSIV runs on Windows, Mac, Linux, iOS (it’s Android + Web only), offers 24/7 phone support (no phone support at all), includes in-person training (just online docs), and has a public API (it doesn’t). Those errors feed downstream LLM hallucinations. Gemini’s “iOS, Web, desktop platforms” line probably came straight from SourceForge.

Claiming the listings is the next step. The vendor account exists, the claim form is a five-minute walkthrough, the moderation takes a few days. Then the listing becomes editable and I’ll fix the errors.

Takeaways

One LLM isn’t all LLMs. Optimizing for ChatGPT alone is a bottleneck. Four parallel search backends matter (Bing, Brave, Google, Common Crawl) plus the directories layer on top.

Software directories are first-class LLM sources. Perplexity cites Slashdot, Gemini cites SourceForge, Claude cites AlternativeTo. Multiplying presence on those directories multiplies citations. They’re the missing layer of classic SEO and they feed AI search directly.

Extended JSON-LD is read. Gemini reproduced the feature list and offers structure straight from the SoftwareApplication schema. Fifty lines of JavaScript in the layout, measurable effect.

IndexNow saves days of crawl cycle. Without push, Bing takes one to three weeks to discover a new site. With a batch, it’s hours. Free, open protocol, supported by Yandex, Naver, Seznam, indirectly all Bing-backed engines.

Most LLM hallucinations come from third-party listings. Errors in SourceForge propagate to Gemini and onward. The fix is to claim and correct the listings, not to over-publish corrections on your own site (which the LLMs already trust).

What’s next

Claim Slashdot + SourceForge to fix the product errors. Submit to ten more directories (G2, Capterra, GetApp, ProductHunt, ToolFinder, FutureTools, etc.). Ship four more /vs/ pages on the same template (Any.do, Google Keep, Notion, TickTick). And measure: test the same four LLMs again in 30 days, 60, 90. If the trajectory holds, you’ll know. If it stagnates, you’ll have the numbers to pivot.

If you want the full breakdown with screenshots and the six-language version, the canonical article is here on the TAMSIV blog.

What’s your current state on AEO? Are you optimizing for one LLM or measuring across the grid?