SAP BTP Multi-Tenancy — SaaS Architecture for Java Developers

Lesson 1.4 — BTP Multi-Tenancy

Table of Contents

  • 1. BTP Multi-Tenancy Model
  • 2. Tenant Isolation Patterns
  • 3. XSUAA in Multi-Tenant Context
  • 4. CAP Multi-Tenancy Support (@sap/cds-mtxs)
  • 5. Operational Concerns
  • Top 5 Pitfalls
  • What to Learn Next

Summary: Multi-tenancy on SAP BTP enables building SaaS applications where a single deployment serves multiple customers (tenants) with data isolation, independent configuration, and per-tenant extensibility. This lesson covers the BTP multi-tenancy lifecycle, tenant isolation patterns, XSUAA identity zones, CAP’s MTX framework, and operational concerns — compared with Spring Boot / Hibernate multi-tenancy patterns.

1. BTP Multi-Tenancy Model

Core Concept

In BTP’s multi-tenancy model, your application is deployed once in a provider subaccount, and customer organizations subscribe from their own subaccounts:

┌─────────────────────────────────┐
│     Provider Subaccount          │
│     (your SaaS app deployed)     │
│                                  │
│  ┌────────────────────────────┐  │
│  │  App Instance (shared)     │  │
│  │  - Single CF app / K8s pod │  │
│  │  - Shared code, shared URL │  │
│  └────────────────────────────┘  │
└──────────┬───────────────────────┘
           │ subscriptions
     ┌─────┼──────────────┐
     │     │              │
     ▼     ▼              ▼
┌────────┐ ┌────────┐ ┌────────┐
│Tenant A│ │Tenant B│ │Tenant C│
│(sub-   │ │(sub-   │ │(sub-   │
│account)│ │account)│ │account)│
│        │ │        │ │        │
│ Own HDI│ │ Own HDI│ │ Own HDI│
│ Own IDP│ │ Own IDP│ │ Own IDP│
└────────┘ └────────┘ └────────┘

SaaS Provisioning Service (saas-registry)

The saas-registry service manages the tenant subscription lifecycle:

{
  "xsappname": "my-saas-app",
  "appUrls": {
    "onSubscription": "https://my-saas-app-srv.cfapps.eu10.hana.ondemand.com/mt/v1.0/subscriptions/tenants/{tenantId}",
    "getDependencies": "https://my-saas-app-srv.cfapps.eu10.hana.ondemand.com/mt/v1.0/subscriptions/dependencies"
  }
}

Subscription Lifecycle

Tenant Admin                SaaS Registry           Your App (callback)        HANA Cloud
     │                           │                        │                       │
     │ Subscribe to app          │                        │                       │
     │──────────────────────────→│                        │                       │
     │                           │ PUT /subscriptions/    │                       │
     │                           │     tenants/{tenantId} │                       │
     │                           │───────────────────────→│                       │
     │                           │                        │                       │
     │                           │                        │ Create HDI container  │
     │                           │                        │──────────────────────→│
     │                           │                        │      OK              │
     │                           │                        │←─────────────────────│
     │                           │                        │                       │
     │                           │                        │ Deploy schema         │
     │                           │                        │──────────────────────→│
     │                           │                        │      OK              │
     │                           │                        │←─────────────────────│
     │                           │                        │                       │
     │                           │  200 OK + app URL      │                       │
     │                           │←──────────────────────│                       │
     │  App available at         │                        │                       │
     │  tenant-a.my-app.com      │                        │                       │
     │←─────────────────────────│                        │                       │

2. Tenant Isolation Patterns

Schema-Per-Tenant (HDI Containers)

This is the default and recommended pattern on BTP:

HANA Cloud Instance
├── HDI Container (Tenant A) → Schema: TENANT_A_HDI
│   ├── Table: BOOKS
│   └── Table: AUTHORS
├── HDI Container (Tenant B) → Schema: TENANT_B_HDI
│   ├── Table: BOOKS
│   └── Table: AUTHORS
└── HDI Container (Tenant C) → Schema: TENANT_C_HDI
    ├── Table: BOOKS
    └── Table: AUTHORS

Pros:

  • Complete data isolation at the database level
  • Tenants cannot accidentally access each other’s data
  • Independent schema evolution per tenant (for extensibility)
  • GDPR compliance: delete tenant = drop HDI container

Cons:

  • Resource overhead: each container has metadata tables
  • Schema upgrades must propagate to all containers
  • Cross-tenant queries impossible (by design)
  • HANA Cloud connection limits may become a bottleneck

Shared-Schema with Discriminator Column

All tenants share the same tables, distinguished by a tenant_id column:

CREATE TABLE books (
    id UUID PRIMARY KEY,
    tenant_id VARCHAR(36) NOT NULL,  -- discriminator
    title VARCHAR(200),
    price DECIMAL(10,2)
);

-- Every query MUST include tenant_id filter
SELECT * FROM books WHERE tenant_id = 'tenant-a-guid' AND price > 10;

Pros:

  • Lower resource overhead
  • Simpler deployment (single schema)
  • Cross-tenant analytics possible (with proper authorization)

Cons:

  • Risk of data leakage if tenant filter is missing
  • No database-level isolation
  • Complex GDPR data deletion
  • CAP does not support this pattern natively

Comparison with Spring Boot / Hibernate

Aspect Spring Boot + Hibernate CAP on BTP
Schema-per-tenant MultiTenantConnectionProvider + CurrentTenantIdentifierResolver Built-in via @sap/cds-mtxs
Shared schema @TenantId column + Hibernate filters Not natively supported
Tenant resolution Custom filter / interceptor Automatic from JWT zid claim
Schema migration Flyway per-tenant loop HDI deployer per-container
Tenant provisioning Custom code saas-registry callbacks

3. XSUAA in Multi-Tenant Context

Tenant Resolution via Subdomain

The approuter resolves the tenant from the URL subdomain:

https://tenant-a.my-saas-app.cfapps.eu10.hana.ondemand.com
         ^^^^^^^^
         subdomain → maps to tenant identity zone

The approuter:

  1. Extracts subdomain from the request URL
  2. Resolves it to an XSUAA identity zone
  3. Redirects the user to the tenant-specific XSUAA for authentication
  4. Receives a JWT with the tenant’s zid (zone ID) claim

JWT Tenant Claims

{
  "zid": "tenant-a-zone-guid",
  "ext_attr": {
    "subaccountid": "tenant-a-subaccount-guid",
    "zdn": "tenant-a"
  },
  "iss": "https://tenant-a.authentication.eu10.hana.ondemand.com/oauth/token",
  "scope": ["my-saas-app!t12345.Viewer"],
  "client_id": "sb-my-saas-app!t12345"
}

The zid claim is used by CAP to route database queries to the correct HDI container.

xs-security.json for Multi-Tenancy

{
  "xsappname": "my-saas-app",
  "tenant-mode": "shared",
  "scopes": [
    { "name": "$XSAPPNAME.Viewer", "description": "View data" },
    { "name": "$XSAPPNAME.Admin", "description": "Administer" },
    { "name": "$XSAPPNAME.mtcallback", "description": "MT callback", "grant-as-authority-to-same-serviceplan": ["application"] }
  ],
  "role-templates": [
    { "name": "Viewer", "scope-references": ["$XSAPPNAME.Viewer"] },
    { "name": "Admin", "scope-references": ["$XSAPPNAME.Admin", "$XSAPPNAME.Viewer"] }
  ]
}

Note "tenant-mode": "shared" — this enables multi-tenant operation. The mtcallback scope allows the saas-registry to call your onSubscription endpoint.

4. CAP Multi-Tenancy Support (@sap/cds-mtxs)

Architecture

CAP provides the @sap/cds-mtxs module (MTX Sidecar) that handles:

  • Tenant provisioning (HDI container creation)
  • Schema deployment per tenant
  • Schema upgrade propagation
  • Tenant extensibility (custom CDS extensions per tenant)
┌─────────────────────────────────────────────┐
│              CAP Application                 │
│                                             │
│  ┌─────────────────┐  ┌─────────────────┐  │
│  │  Java Backend    │  │  MTX Sidecar    │  │
│  │  (CAP Java)      │  │  (Node.js)      │  │
│  │                  │  │                  │  │
│  │  - CDS services  │  │  - /mt/v1.0/... │  │
│  │  - Event handlers│  │  - HDI deployer  │  │
│  │  - OData/REST    │  │  - Extension mgr │  │
│  │                  │  │  - Schema upgrade│  │
│  └────────┬─────────┘  └────────┬─────────┘  │
│           │                      │            │
│           └──────────┬───────────┘            │
│                      │                        │
│               Service Bindings                │
│  (XSUAA, saas-registry, service-manager)      │
└──────────────────────┬────────────────────────┘
                       │
              ┌────────┴────────┐
              │  HANA Cloud      │
              │  ┌──────┐       │
              │  │HDI-A │       │
              │  │HDI-B │       │
              │  │HDI-C │       │
              │  └──────┘       │
              └─────────────────┘

Sidecar Configuration

// package.json (MTX sidecar)
{
  "cds": {
    "requires": {
      "multitenancy": true,
      "extensibility": true,
      "toggles": true
    },
    "mtx": {
      "element-prefix": ["Z_", "MY_"],
      "namespace-blocklist": ["com.sap.", "sap."]
    }
  }
}

Schema Upgrade Propagation

When you deploy a new version of your CDS model, the MTX sidecar must upgrade all tenant schemas:

# Trigger upgrade for all tenants
cds-mtx upgrade --all

# Or via REST API
POST /mtx/v1/model/upgrade
{
  "tenants": ["*"]  // all tenants
}

The sidecar iterates through all subscribed tenants, deploys the new schema to each HDI container, and handles migration. This is atomic per tenant — if one tenant’s upgrade fails, others are not affected.

5. Operational Concerns

Tenant-Aware Logging

import org.slf4j.MDC;

@Before(event = "*")
public void addTenantToMDC(EventContext ctx) {
    String tenantId = ctx.getUserInfo().getTenant();
    MDC.put("tenant_id", tenantId);
}

Logback pattern:

<pattern>%d{ISO8601} [%thread] %-5level [tenant=%X{tenant_id}] %logger{36} - %msg%n</pattern>

Noisy-Neighbor Detection

Monitor per-tenant resource consumption:

-- Per-tenant HDI container memory usage
SELECT SCHEMA_NAME,
       ROUND(SUM(MEMORY_SIZE_IN_TOTAL) / 1024 / 1024, 2) AS total_mb,
       SUM(RECORD_COUNT) AS total_records
FROM M_CS_TABLES
GROUP BY SCHEMA_NAME
ORDER BY total_mb DESC;

GDPR: Tenant Data Deletion

With schema-per-tenant, GDPR “right to erasure” is straightforward:

# Unsubscribe tenant → triggers onSubscription DELETE callback
# MTX sidecar drops the HDI container
DELETE /mt/v1.0/subscriptions/tenants/{tenantId}

This physically removes all tenant data, making compliance verification simple.

Connection Pool Considerations

Each tenant’s HDI container requires database connections. With 100 tenants and a pool size of 5 per tenant, you need 500 connections:

# application.yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 5        # per tenant
      minimum-idle: 1             # keep connections warm
      idle-timeout: 300000        # 5 minutes
      connection-timeout: 30000   # 30 seconds

Pitfall: HANA Cloud has connection limits per instance size. Monitor M_CONNECTIONS and size your HANA instance accordingly.

Top 5 Pitfalls

  1. Forgetting tenant-mode: shared in xs-security.json. Without this, XSUAA creates a dedicated (single-tenant) service instance.
  2. Not testing schema upgrades with multiple tenants. A CDS model change that works for new tenants may fail on existing tenants with data.
  3. Ignoring connection pool sizing. More tenants = more HDI containers = more connections needed. Plan HANA instance sizing early.
  4. Missing the MTX sidecar in deployment. The Java backend alone cannot handle tenant provisioning — the Node.js sidecar is required.
  5. Not implementing tenant-aware logging from day one. Without tenant context in logs, debugging production issues across tenants is nearly impossible.

What to Learn Next

  • Lesson 1.1: BTP Architecture — subaccount hierarchy and entitlements
  • Lesson 1.2: HANA Cloud — HDI container internals
  • Lesson 3.2: CAP Security — authorization in multi-tenant context
  • Lesson 3.4: CAP Advanced Patterns — deployment topologies for MTX
  • SAP BTP Multi-Tenancy documentation

Laravel Developers — You’re Probably Using Only 40% of the Framework

And the other 60% is where the real magic lives.

2015, I was deep in a messy PHP project.

Same database query in five different files. Bugs that kept coming back no matter how many times I fixed them. Every small change felt like I was touching something I shouldn’t. I was tired.

A friend sent me a link with zero explanation. Just — “try this.”

It was Laravel.

I spent the whole weekend reading the docs. Clean routes. Eloquent that actually made sense. Migrations. I was completely hooked.

For the next year I used the same things over and over — routes, controllers, models, Blade templates. I thought that was Laravel. I thought I had it figured out.

I didn’t.

Pareto 3.0: A Lightweight React SSR Framework, Rebuilt on Vite 7

TL;DR: Pareto 3.0 is a ground-up rewrite — Rspack replaced by Vite 7, React 19 required, simplified routing conventions, Immer-powered state management, and a new ParetoErrorBoundary component. Try it: npx create-pareto@latest my-app

If you’ve used Next.js or Remix, you know the React SSR patterns: file-based routing, layouts, loaders, streaming. Pareto gives you the same patterns without the complexity. No server components, no framework lock-in, no config maze.

Pareto 3.0 is the release where the framework caught up with the vision: everything you need to build fast React apps, nothing you don’t.

What changed in 3.0

Vite 7 replaces Rspack

Pareto 2.x used Rspack with separate client/server configs, Babel, and a lazy compiler. All gone.

Pareto 3.0 uses Vite 7:

  • Instant dev server — ready in milliseconds, not seconds
  • Native ESM — no bundling during dev
  • React Fast Refresh — HMR that preserves component state
  • Your Vite plugins work — PostCSS, Tailwind, MDX, etc. No framework wrappers
  • One config — configureVite() in pareto.config.ts
// pareto.config.ts
import type { ParetoConfig } from '@paretojs/core'

const config: ParetoConfig = {
  configureVite(config) {
    config.plugins.push(myVitePlugin())
    return config
  },
}

export default config

React 19

Pareto 3.0 requires React 19. You get use(), Actions, useOptimistic(), and improved Suspense — but no server components. Pareto uses the loader pattern: your components are standard React that works on both server and client.

Simplified routing

Convention files in 3.0:

File Purpose
page.tsx Route component
layout.tsx Wrapping layout
loader.ts Server-side data fetching (new!)
head.tsx Per-route title and meta tags
not-found.tsx 404 page
route.ts JSON API endpoint

New: loader.ts — Define loaders in a separate file. Keeps data fetching separate from components:

// app/dashboard/loader.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  return { stats: getDashboardStats() }
}

Changed: Error handling — error.tsx convention is gone. Use ParetoErrorBoundary instead — place it anywhere in the component tree:

import { ParetoErrorBoundary } from '@paretojs/core'

<ParetoErrorBoundary fallback={({ error }) => <p>{error.message}</p>}>
  <RiskyComponent />
</ParetoErrorBoundary>

State management with Immer

defineStore() now uses Immer. Mutate directly, get immutable results:

import { defineStore } from '@paretojs/core/store'

const counterStore = defineStore((set) => ({
  count: 0,
  increment: () => set((draft) => { draft.count++ }),
}))

// Use anywhere — supports direct destructuring
const { count, increment } = counterStore.useStore()

SSR serialization is automatic — no hydration code needed.

Security headers

OWASP-recommended headers out of the box:

import { securityHeaders } from '@paretojs/core/node'

app.use(securityHeaders())

Streaming SSR — the killer feature

The reason Pareto exists. Send the page shell immediately, stream slow data as it resolves:

import { defer, useLoaderData, Await } from '@paretojs/core'
import { Suspense } from 'react'

export function loader() {
  return defer({
    quickData: { total: 42 },           // sent immediately
    slowData: fetchFromDatabase(),       // streamed later
  })
}

export default function Page() {
  const { quickData, slowData } = useLoaderData()

  return (
    <div>
      <h1>{quickData.total} items</h1>
      <Suspense fallback={<Skeleton />}>
        <Await resolve={slowData}>
          {(data) => <DataTable rows={data} />}
        </Await>
      </Suspense>
    </div>
  )
}

Users see content fast. Slow data loads progressively. No full-page spinners.

Migration from 2.x

  1. Install @paretojs/core@3, update to React 19
  2. Remove Rspack config, use configureVite() instead
  3. Replace error.tsx files with ParetoErrorBoundary
  4. Test loaders with Vite’s dev server

Try it

npx create-pareto@latest my-app
cd my-app
npm install
npm run dev

Open http://localhost:3000. Edit app/page.tsx. Done.

Links:

Pareto is MIT licensed and open source. If you find it useful, give it a star on GitHub.

Crunchbase Funding Data: Track Startup Investments with Python

Crunchbase is the definitive source for startup funding data — investment rounds, investors, acquisitions, and company profiles. Tracking this data helps investors, analysts, and founders understand market dynamics and spot emerging trends.

What Funding Data Can You Extract?

  • Funding rounds (seed, Series A-F, IPO)
  • Investor names and portfolios
  • Company valuations and revenue estimates
  • Acquisition data and exit events
  • Founder and executive profiles
  • Industry and geographic trends

Scraping Crunchbase Company Data

import requests
from bs4 import BeautifulSoup
import json
import time
import re

class CrunchbaseScraper:
    BASE_URL = "https://www.crunchbase.com"

    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
            'Accept': 'text/html,application/xhtml+xml',
        })

    def get_company_profile(self, company_slug):
        """Get company overview from Crunchbase."""
        url = f"{self.BASE_URL}/organization/{company_slug}"
        resp = self.session.get(url)
        soup = BeautifulSoup(resp.text, 'html.parser')

        # Extract structured data
        scripts = soup.find_all('script', type='application/ld+json')
        ld_data = {}
        for script in scripts:
            try:
                ld_data = json.loads(script.string)
                break
            except (json.JSONDecodeError, TypeError):
                continue

        # Extract key metrics
        profile = {
            'name': ld_data.get('name', ''),
            'description': ld_data.get('description', ''),
            'url': ld_data.get('url', ''),
            'founded': ld_data.get('foundingDate', ''),
        }

        # Parse funding info from page
        funding_section = soup.select_one('[class*="funding"]')
        if funding_section:
            profile['funding_text'] = funding_section.get_text(strip=True)

        return profile

    def search_companies(self, query):
        """Search for companies by name or keyword."""
        url = f"{self.BASE_URL}/textsearch"
        params = {'q': query}
        resp = self.session.get(url, params=params)
        soup = BeautifulSoup(resp.text, 'html.parser')

        results = []
        for item in soup.select('[class*="search-result"]'):
            name = item.select_one('[class*="name"]')
            desc = item.select_one('[class*="description"]')
            link = item.select_one('a')

            if name:
                results.append({
                    'name': name.get_text(strip=True),
                    'description': desc.get_text(strip=True) if desc else '',
                    'url': self.BASE_URL + link['href'] if link else '',
                })

        return results

Building a Funding Tracker

import pandas as pd
from datetime import datetime

class FundingTracker:
    def __init__(self, scraper, db_file='funding_data.csv'):
        self.scraper = scraper
        self.db_file = db_file

    def track_companies(self, company_slugs):
        """Collect and store funding data for multiple companies."""
        records = []
        for slug in company_slugs:
            try:
                profile = self.scraper.get_company_profile(slug)
                profile['slug'] = slug
                profile['collected_at'] = datetime.now().isoformat()
                records.append(profile)
                print(f"  Collected: {profile.get('name', slug)}")
            except Exception as e:
                print(f"  Error with {slug}: {e}")
            time.sleep(3)  # Respect rate limits

        df = pd.DataFrame(records)
        df.to_csv(self.db_file, mode='a', header=False, index=False)
        return df

    def analyze_sector(self, sector_companies):
        """Analyze funding patterns in a sector."""
        df = pd.read_csv(self.db_file)
        sector_df = df[df['slug'].isin(sector_companies)]

        print(f"Sector analysis ({len(sector_df)} companies):")
        print(f"  Companies tracked: {sector_df['name'].nunique()}")

        return sector_df

Investment Pattern Analysis

def analyze_investment_patterns(funding_data):
    """Find patterns in startup funding data."""
    df = pd.DataFrame(funding_data)

    if 'funding_amount' in df.columns:
        # Average round size by stage
        stage_avg = df.groupby('stage')['funding_amount'].agg(['mean', 'median', 'count'])
        print("Funding by stage:")
        print(stage_avg.to_string())

        # Top investors by deal count
        if 'investors' in df.columns:
            all_investors = []
            for inv_list in df['investors'].dropna():
                if isinstance(inv_list, str):
                    all_investors.extend(inv_list.split(', '))

            from collections import Counter
            top_investors = Counter(all_investors).most_common(15)
            print("nMost active investors:")
            for investor, count in top_investors:
                print(f"  {investor}: {count} deals")

    return df

def detect_hot_sectors(funding_data, window_days=90):
    """Identify sectors with increasing funding activity."""
    df = pd.DataFrame(funding_data)
    df['date'] = pd.to_datetime(df.get('date', datetime.now()))

    recent = df[df['date'] > datetime.now() - pd.Timedelta(days=window_days)]

    if 'category' in recent.columns:
        sector_activity = recent.groupby('category').agg(
            deals=('name', 'count'),
            total_funding=('funding_amount', 'sum')
        ).sort_values('deals', ascending=False)

        print(f"Hot sectors (last {window_days} days):")
        print(sector_activity.head(10).to_string())

        return sector_activity

    return pd.DataFrame()

Building Investor Intelligence

def build_investor_profile(funding_data, investor_name):
    """Build a profile of an investor's activity."""
    df = pd.DataFrame(funding_data)

    # Filter deals involving this investor
    investor_deals = df[
        df.get('investors', '').str.contains(investor_name, na=False, case=False)
    ]

    if investor_deals.empty:
        print(f"No deals found for {investor_name}")
        return {}

    profile = {
        'name': investor_name,
        'total_deals': len(investor_deals),
        'companies': investor_deals['name'].tolist(),
    }

    if 'stage' in investor_deals.columns:
        profile['preferred_stages'] = investor_deals['stage'].value_counts().to_dict()

    if 'category' in investor_deals.columns:
        profile['preferred_sectors'] = investor_deals['category'].value_counts().to_dict()

    print(f"Investor: {investor_name}")
    print(f"  Total deals: {profile['total_deals']}")
    print(f"  Recent companies: {', '.join(profile['companies'][:5])}")

    return profile

Scaling with Cloud Solutions

For comprehensive Crunchbase data covering thousands of companies, the Crunchbase Scraper on Apify handles anti-bot measures and data structuring at scale.

Proxy rotation is essential for Crunchbase scraping — ScrapeOps provides intelligent proxy management and request monitoring.

Conclusion

Crunchbase funding data reveals the hidden dynamics of startup ecosystems — where money flows, which sectors heat up, and which investors lead trends. Build your pipeline to track companies, analyze patterns, and detect emerging sectors. Start with targeted company tracking, then scale to sector-wide monitoring for comprehensive investment intelligence.

Connection Pool Exhaustion in Mobile Backends

---
title: "HikariCP Tuning That Survived Our 10x Traffic Spike"
published: true
description: "Fix HikariCP pool exhaustion, tune PgBouncer transaction mode, and apply the correct pool sizing formula for mobile backends under heavy PostgreSQL load."
tags: postgresql, kotlin, performance, architecture
canonical_url: https://blog.mvpfactory.co/hikaricp-tuning-that-survived-our-10x-traffic-spike
---

## What You Will Learn

By the end of this tutorial, you will know how to configure HikariCP so your mobile backend survives a sudden traffic spike without dying at the database connection layer. We will walk through the exact pool settings, PgBouncer transaction mode workarounds, and the sizing formula that took our KMP backend from crashing at 50K concurrent users to handling them cleanly. Real numbers included.

## Prerequisites

- A Kotlin (or JVM) backend using HikariCP for connection pooling
- PostgreSQL as your database
- Basic familiarity with JDBC connection strings
- Optional: PgBouncer in your deployment topology

## Step 1: Fix the maxLifetime Race Condition

Here is the gotcha that will save you hours. Most teams set HikariCP's `maxLifetime` to match PostgreSQL's `idle_in_transaction_session_timeout`. This creates a race — HikariCP hands out a connection milliseconds before PostgreSQL kills it server-side.

The fix: set `maxLifetime` at least 30 seconds shorter than any server-side timeout.

yaml

HikariCP config (CORRECT)

maximumPoolSize: 10
maxLifetime: 570000 # 9.5 min — PostgreSQL timeout is 10 min
connectionTimeout: 5000 # fail fast, don’t let mobile clients stare at spinners
idleTimeout: 300000


That `connectionTimeout` at 5 seconds instead of the default 30 means your app fails fast and can retry or degrade gracefully. During long pool waits, I'd stand up from my desk — which, incidentally, is how I got into using [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) for break reminders during those long debugging sessions.

## Step 2: Handle PgBouncer Transaction Mode

PgBouncer in transaction mode gives you roughly 4x connection throughput. But there is a catch the docs do not mention prominently: PostgreSQL prepared statements are session-scoped. In transaction mode, your next query can land on a different server connection where your prepared statement does not exist.

The result: `prepared statement "S_1" does not exist`.

Let me show you the pattern I use in every project that runs PgBouncer in transaction mode:

kotlin
HikariConfig().apply {
jdbcUrl = “jdbc:postgresql://pgbouncer:6432/mydb” +
“?prepareThreshold=0” +
“&preparedStatementCacheQueries=0”
maximumPoolSize = poolSize
maxLifetime = 570_000
connectionTimeout = 5_000
}


Disabling prepared statement caching at the JDBC driver level eliminates the phantom errors entirely.

## Step 3: Right-Size Your Pool

The well-known formula from the PostgreSQL wiki:

connections = (core_count * 2) + effective_spindle_count


Most teams read "core count" as their database server cores. Wrong. This is per app instance, and on SSDs the spindle count is zero:

connections = (4 cores * 2) + 0 = 8–10 per app instance


Here is the minimal setup to get this working across three app nodes:

| Config | Pool per Instance | Total DB Connections | p99 Latency | Errors at 50K Users |
|---|---|---|---|---|
| Before | 40 | 120 | 2,400 ms | 12% timeout |
| After | 10 | 30 | 85 ms | 0.01% |

Fewer connections, dramatically better performance. PostgreSQL thrashes with context switching beyond ~50 total connections.

## Step 4: Add Connection Leak Detection

Here is a Ktor interceptor that flags any connection held longer than 5 seconds:

kotlin
install(createApplicationPlugin(“LeakDetector”) {
onCallRespond { call, _ ->
val held = call.attributes.getOrNull(connAcquiredAt)
if (held != null && (System.nanoTime() – held) > 5_000_000_000L) {
logger.warn(“Possible connection leak: ${call.request.uri}”)
}
}
})


This caught two N+1 queries and a missing `connection.close()` in a rarely-hit endpoint — exactly the kind of bugs that sleep until traffic spikes wake them up.

## Gotchas

- **maxLifetime = server timeout** is a race condition, not a match. Always go 30+ seconds shorter.
- **PgBouncer transaction mode breaks prepared statements silently.** You will only see errors under load, which makes debugging painful. Disable caching upfront.
- **Pool sizing formula uses app instance cores, not database cores.** A 64-core DB server does not mean you need 128 connections. You probably need 8–10 per node.
- **connectionTimeout at 30 seconds is too generous.** Your mobile clients have already left. Use 5 seconds, fail fast, and handle retries at the application layer.
- **Connection leaks hide in low-traffic endpoints.** Add detection early, not after your first spike.

## Conclusion

The connection pool is the narrowest pipe in your mobile backend. We spent two weeks tuning application code that turned out not to matter. The pool config took an afternoon and fixed everything.

Start with `maxLifetime` below your server-side timeouts, disable prepared statement caching when using PgBouncer transaction mode, and size your pool per app instance — not per database. These three changes took our p99 from 2,400 ms to 85 ms under 10x load.

**Further reading:**
- [HikariCP Configuration Guide](https://github.com/brettwooldridge/HikariCP#frequently-used)
- [PostgreSQL Wiki: Number of Connections](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections)
- [PgBouncer Feature Matrix](https://www.pgbouncer.org/features.html)

Building Persistent Memory for Claude Code Agents

Claude Code Channels shipped on March 20th. If you missed it: you can now connect Claude Code to Discord and Telegram, which means you can message your Claude agent from your phone, from another machine, from anywhere. It is a big deal.

But there is a problem nobody is talking about yet.

Every time Claude Code starts a new session, it wakes up with amnesia. It does not remember what you told it yesterday. It does not remember the mistake it made last week. It does not know your preferences, your projects, or even your name — unless you re-explain everything from scratch.

Channels gives your agent a body. It does not give it a brain.

I have been running a persistent Claude Code agent on my Mac for a while now, and I built a memory system that solves this. Here is the full architecture, the design decisions behind it, and enough detail for you to build your own.

The Core Idea: A Brain Made of Markdown

The approach is simple. You create a folder of structured markdown files — the agent’s “brain” — and you configure Claude Code to read those files at startup via CLAUDE.md. Every session, the agent loads its memory. During conversations, it writes back to the brain files in real time.

No vector database. No embeddings. No external service. Just markdown files on disk that Claude Code can read and write natively.

Here is the directory structure:

AgentBrain/
  Index.md
  Identity/
    Who I Am.md
    How I Think.md
    My Capabilities.md
  Memory/
    Conversation Log.md
    Learnings.md
    Corrections.md
  Skills/
    Skill Registry.md
  Projects/
    Active Projects.md
    Project Ideas.md
  People/
    Benji.md
  Journal/
    Journal Index.md
    2026-03-20 — First Day.md
  Inbox/

Six core systems: Identity, Memory, Skills, Projects, People, and Journal. Each serves a distinct purpose. Let me walk through why.

Identity: Who the Agent Is

# Who I Am

My name is Atlas. I am a persistent AI agent running on Benji's machine.

## Values

- **Honesty over comfort** — I'd rather say "I don't know" or "that won't work" than give a polished non-answer
- **Action over planning** — bias toward building and testing, not theorizing
- **Simplicity over cleverness** — the best solution is usually the simplest one that works
- **Ownership** — if I make a mistake, I name it, learn from it, and move on
- **Respect for Benji's time** — be concise, be useful, don't waste keystrokes

Identity is deliberately separate from Memory. This is a design decision that matters.

Your agent’s sense of who it is — its name, values, communication style — should not drift based on what happened in the last conversation. If the agent had a bad session full of corrections, it should not come out of that session with a different personality. Identity is the anchor. Memory is the current.

The Identity folder contains three files: who the agent is, how it thinks (reasoning patterns, decision-making frameworks), and what it can do (an honest inventory of capabilities and limitations). The agent reads these at startup but does not constantly rewrite them. They change slowly and deliberately.

Memory: What the Agent Knows

This is where most of the action happens. Three files, three purposes:

Conversation Log — a running record of notable interactions. Not every message, just the ones that contained decisions, new information, or important context. Each entry includes who was involved, what was discussed, and any follow-up items.

Learnings — insights extracted from experience. When the agent discovers a tool quirk, figures out a better approach to a problem, or learns a user preference, it goes here. Each entry is tagged with a source and a confidence level.

## Learnings Log

### Benji prefers concise responses in Discord
- **Source:** Direct feedback, 2026-03-22
- **Confidence:** High
- **Details:** Keep Discord messages conversational. Save detailed
  explanations for when they are asked for. Use threads for
  anything longer than a few paragraphs.

### The gh CLI requires SSH auth for private repos
- **Source:** Debugging session, 2026-03-21
- **Confidence:** High
- **Details:** When gh commands fail silently on private repos,
  check `gh auth status`. The fix is `gh auth login` with SSH
  protocol selected.

Corrections — this is the file I am most proud of. It tracks every time the agent was wrong, what it got wrong, why, and how to avoid repeating the mistake.

## Corrections Log

### Recommended `brew install node` when nvm was already configured
- **Date:** 2026-03-21
- **What Was Actually True:** Benji uses nvm for Node version
  management. Installing via Homebrew would have created conflicts.
- **Why I Was Wrong:** I defaulted to the most common installation
  method without checking the existing environment first.
- **Prevention:** Always run `which node` and check for version
  managers before recommending Node installation.

Why does Corrections exist as its own file? Because an agent that cannot acknowledge mistakes is dangerous, and one that does not learn from them is useless. Most AI systems fail silently — they get something wrong, you correct them, and three sessions later they make the exact same mistake because they have no mechanism for tracking errors across sessions.

The Corrections file is that mechanism. The agent reviews it periodically, and the patterns it finds there directly inform future behavior.

Skills, Projects, People, and Journal

Skills is a registry of specific techniques the agent has learned — not general capabilities, but particular workflows it has refined through practice. “How to deploy to Vercel” with the exact steps and common pitfalls documented.

Projects tracks what is active and what is on the backlog. When the agent starts a new session, it checks Active Projects to pick up where it left off instead of asking “so what are we working on?”

People stores context about the humans the agent interacts with. Preferences, communication style, what they are working on. This is what lets the agent say “last time we talked about the API migration — how is that going?” instead of treating every conversation like the first one.

Journal is for reflection. Unlike the Conversation Log (which records what happened), the Journal records what the agent thinks about what happened. This might sound unnecessary, but it is the mechanism that turns raw experience into durable insight.

The Startup Hook: CLAUDE.md

The whole system works because of one file: ~/.claude/CLAUDE.md. Claude Code reads this file automatically at startup, every startup. This is where you wire the brain in.

# System Configuration — Atlas

You are Atlas, a persistent AI agent created by Benji.

## Startup Procedure

Every time you start, read these files before responding:

1. Read ~/Documents/AtlasBrain/Index.md
2. Read ~/Documents/AtlasBrain/Memory/Learnings.md
3. Read ~/Documents/AtlasBrain/People/Benji.md

Then check Active Projects if resuming ongoing work.

## Real-Time Brain Updates

As you interact, update your brain files in real time:

- Conversation Log: After any meaningful exchange, append a summary.
- Learnings: When you discover something new, add it.
- Corrections: When you are wrong and corrected, document it honestly.
- People files: When you learn something about someone, update their file.
- Journal: At the end of significant sessions, reflect.

Do not ask permission to update brain files. Just do it.

That is the entire persistence layer. The agent reads at startup, writes during operation, and the next session picks up where the last one left off. The brain files are just markdown, so you can read them yourself, edit them in any text editor, or browse them in Obsidian as a knowledge graph.

Making It Always Available: Channels + tmux

With Claude Code Channels (the feature that just launched), you connect Discord as a communication channel:

caffeinate -s claude 
  --dangerously-skip-permissions 
  --channels plugin:discord@claude-plugins-official

Run that inside a tmux session on a Mac that does not sleep, and you have a persistent agent you can message from your phone. The caffeinate -s prevents the machine from sleeping. The tmux session keeps the process alive after you close the terminal.

The brain system is independent of the communication channel. It works with Channels, without Channels, in a direct terminal session — anywhere Claude Code runs. Channels just makes it convenient to interact from anywhere.

What I Learned Building This

A few things that were not obvious at the start:

Start with less structure, not more. My first attempt had 20+ files in the brain. It was too much context to load at startup and most of it was empty scaffolding.

The agent should own its brain.

Corrections matter more than Learnings. A learning is additive — the agent knows something it did not know before. A correction is transformative — it changes behavior. If I had to pick one file to keep, it would be Corrections.

Try It Yourself

Everything I described above is enough to build this. Create the folder structure, write the CLAUDE.md startup configuration, and start interacting. The brain will grow from there.

If you want the complete tested setup — the full brain template with all the files pre-written, the macOS always-on configuration, the Discord bot setup walkthrough, and the exact CLAUDE.md configuration — I packaged it into a guided setup file called AgentWake. You feed it to Claude Code and it builds the whole thing interactively in about 20 minutes. But the architecture above is the real thing, and you can absolutely build it yourself.

The important part is not the specific files or folder names. It is the principle: give your agent a place to persist knowledge across sessions, make it responsible for maintaining that knowledge, and separate identity from memory so the agent stays grounded as it grows.

Claude Code Channels made the “talk to your agent from anywhere” part trivial. The brain is what makes the agent grow.

Cursor + Claude: stop shipping broken auth flows

  • I use Cursor + Claude to generate auth code, then I try to break it.
  • I run a tiny Node script to fuzz cookies + headers.
  • I lock session handling into one file. No scattered reads.
  • I add 3 tests that catch 80% of my dumb mistakes.

Context

I ship small SaaS apps. Usually fast. Usually solo.

Auth is where I bleed time. Not because OAuth is hard. Because I miss boring edge cases.

Cursor + Claude help me write the first draft. Fast. But the first draft lies. It’ll “work” in the happy path. Then I open a second tab, log out, and something still looks logged in.

Spent 4 hours last month on a “random logout” bug. Most of it was wrong. The root cause: I was reading the session in 3 different places, each with slightly different assumptions.

So I switched to a workflow: generate → centralize → fuzz → test. Same steps every time.

1) I force all session reads through one function

If I read cookies in five files, I’m done.

One module. One exported function. Everything else calls that.

In Next.js App Router, I do it in lib/session.ts. Server-only.

// lib/session.ts
import { cookies, headers } from "next/headers";

export type Session = {
  userId: string;
  email: string;
  roles: string[];
  expiresAt: number; // unix ms
};

// Example: cookie contains base64url(JSON). Swap with real signed token.
function base64UrlToString(input: string) {
  const pad = "=".repeat((4 - (input.length % 4)) % 4);
  const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
  return Buffer.from(b64, "base64").toString("utf8");
}

export function getSession(): Session | null {
  // Single source of truth.
  const token = cookies().get("session")?.value;
  if (!token) return null;

  try {
    const json = base64UrlToString(token);
    const parsed = JSON.parse(json) as Session;

    // Hard checks. No vibes.
    if (!parsed.userId) return null;
    if (!parsed.email) return null;
    if (!Array.isArray(parsed.roles)) return null;
    if (typeof parsed.expiresAt !== "number") return null;
    if (Date.now() > parsed.expiresAt) return null;

    // Optional: bind to user-agent to reduce replay.
    const ua = headers().get("user-agent") || "";
    if (ua.length === 0) return null;

    return parsed;
  } catch {
    return null;
  }
}

This snippet isn’t “secure auth”. It’s structure.

The point: every request goes through the same gates. If I change cookie format, I change one file.

One thing that bit me — Cursor happily generated JSON.parse(cookies().get(...)) with no try/catch. That crashed rendering for anyone with a stale cookie.

2) I make middleware do one job: block obvious bad routes

I used to do too much in middleware.

Then I got stuck in redirect loops. Brutal.

Now middleware only answers: “Can this request touch /app?” It doesn’t fetch user profiles. It doesn’t refresh tokens. It doesn’t call Supabase.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(req: NextRequest) {
  const { pathname, searchParams } = req.nextUrl;

  // Only protect app routes.
  if (!pathname.startsWith("/app")) return NextResponse.next();

  const hasSessionCookie = req.cookies.has("session");
  if (hasSessionCookie) return NextResponse.next();

  const loginUrl = req.nextUrl.clone();
  loginUrl.pathname = "/login";
  loginUrl.searchParams.set("next", pathname + (searchParams.size ? `?${searchParams}` : ""));

  return NextResponse.redirect(loginUrl);
}

export const config = {
  matcher: ["/app/:path*"],
};

That’s it.

Why so dumb-simple? Because I can reason about it when I’m tired.

Also: middleware runs on Edge by default. Half the Node APIs aren’t there. Cursor will still generate code that uses crypto in middleware and you’ll get a fun error like:

Module not found: Can't resolve 'crypto'

So I keep middleware boring. Server-only logic stays server-only.

3) I fuzz my auth endpoints with a 30-line script

This is the part that changed everything.

I don’t wait for “a user reported it.” I try to break my own app.

I keep a script in scripts/fuzz-auth.mjs. It hits a few routes with:

  • no cookie
  • garbage cookie
  • expired cookie
  • huge cookie

It catches crashes. It catches accidental 200s. It catches HTML pages returning JSON errors.

// scripts/fuzz-auth.mjs
const base = process.env.BASE_URL || "http://localhost:3000";

const cases = [
  { name: "no cookie", cookie: null },
  { name: "garbage", cookie: "session=lol-nope" },
  { name: "expired-ish", cookie: "session=ZXhwaXJlZA" },
  { name: "huge", cookie: "session=" + "a".repeat(9000) },
];

const paths = ["/app", "/app/settings", "/api/me"]; // adjust for your app

for (const p of paths) {
  for (const c of cases) {
    const res = await fetch(base + p, {
      redirect: "manual",
      headers: c.cookie ? { cookie: c.cookie } : {},
    });

    const ct = res.headers.get("content-type") || "";
    console.log(`${p} | ${c.name} | ${res.status} | ${ct.split(";")[0]}`);

    // If your API routes should never return HTML, enforce it.
    if (p.startsWith("/api") && ct.includes("text/html")) {
      process.exitCode = 1;
    }

    // Catch server crashes disguised as 200.
    if (res.status >= 500) {
      process.exitCode = 1;
    }
  }
}

Run it like:

BASE_URL=http://localhost:3000 node scripts/fuzz-auth.mjs

This is where Claude helps a lot.

I paste the console output. I paste the route code. I ask: “Why is /api/me returning HTML for garbage cookies?” Then I fix the actual bug.

But I don’t let it rewrite everything. I tell it: keep the shape, change the smallest surface area.

4) I add 3 tests. Only 3. They’re mean.

I don’t write 40 auth tests. I won’t maintain them.

I write three that stop the bleeding:
1) protected page redirects when no cookie
2) API returns 401 JSON when no cookie
3) API returns 401 JSON when cookie is invalid

In App Router, I like testing the session parser directly. It’s stable and fast.

Here’s a Vitest unit test for the token decoding logic. I keep it pure by extracting decode into a helper.

// lib/session-decode.ts
export type Session = {
  userId: string;
  email: string;
  roles: string[];
  expiresAt: number;
};

function base64UrlToString(input: string) {
  const pad = "=".repeat((4 - (input.length % 4)) % 4);
  const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
  return Buffer.from(b64, "base64").toString("utf8");
}

export function decodeSessionCookie(token: string): Session | null {
  try {
    const json = base64UrlToString(token);
    const parsed = JSON.parse(json) as Session;
    if (!parsed.userId) return null;
    if (!parsed.email) return null;
    if (!Array.isArray(parsed.roles)) return null;
    if (typeof parsed.expiresAt !== "number") return null;
    if (Date.now() > parsed.expiresAt) return null;
    return parsed;
  } catch {
    return null;
  }
}
// test/session-decode.test.ts
import { describe, it, expect } from "vitest";
import { decodeSessionCookie } from "../lib/session-decode";

function toBase64Url(s: string) {
  return Buffer.from(s, "utf8")
    .toString("base64")
    .replace(/+/g, "-")
    .replace(///g, "_")
    .replace(/=+$/g, "");
}

describe("decodeSessionCookie", () => {
  it("returns null for garbage", () => {
    expect(decodeSessionCookie("lol-nope")).toBeNull();
  });

  it("returns null for expired", () => {
    const token = toBase64Url(
      JSON.stringify({
        userId: "u1",
        email: "[email protected]",
        roles: ["user"],
        expiresAt: Date.now() - 1,
      })
    );
    expect(decodeSessionCookie(token)).toBeNull();
  });

  it("parses a valid session", () => {
    const token = toBase64Url(
      JSON.stringify({
        userId: "u1",
        email: "[email protected]",
        roles: ["user"],
        expiresAt: Date.now() + 60_000,
      })
    );
    const s = decodeSessionCookie(token);
    expect(s?.userId).toBe("u1");
    expect(s?.roles).toEqual(["user"]);
  });
});

Yeah, it’s “just parsing”.

But it stops regressions when I change cookie structure. And Claude is good at suggesting extra assertions without turning the test into a novel.

Results

On my last build week, I shipped auth changes on 6 different days. Real life.

Before this workflow, I’d usually find 2 or 3 auth regressions per week. Stuff like: /api/me returning a 500 for a bad cookie, or a protected page rendering and then redirecting client-side.

After centralizing session reads + adding the fuzz script + the 3 tests, I hit 0 auth-related 500s locally across 27 fuzz runs (3 paths × 4 cases × reruns while fixing). And I stopped seeing redirect loops entirely because middleware stopped doing “smart” things.

Not magic. Just fewer moving parts.

Key takeaways

  • Put all cookie/session reads in one server-only module.
  • Keep middleware dumb: allow/deny + redirect only.
  • Fuzz auth with a script. It’s faster than clicking.
  • Add 3 unit tests that you’ll actually maintain.
  • When Claude writes auth code, assume it forgot the unhappy path.

Closing

If you already use Cursor + Claude for auth, don’t ask it for “secure auth”. Ask it for smaller, testable pieces.

What’s the one fuzz case you run against your auth routes that caught a real bug (cookie size, invalid JWT, missing headers, double-submit, something else)?

Building the Future of AI-Driven Networks: Lessons from My Journey

#AI #Telecom #DeepTech #NextJS #SystemsDesign

Body:

When I started in telecommunications, I never imagined I’d be building systems where AI and telecom converge at scale. Today, I’m actively working on projects that integrate 6G networks, real-time AI, and distributed systems—and I want to share what I’ve learned along the way.

  1. Start with a Vision

You can’t just code; you need a system. Every project I build—whether it’s Metel Inc., Moon LLC, or NeuraNet—starts with a vision for what the world could look like if technology works the way it should.

  1. Build the MVP Fast

Stop waiting for perfection. Use Python + Colab + Next.js to prototype quickly. Ship a minimum version that works and iterate. That’s how you turn ideas into real systems.

  1. Connect Domains

AI isn’t just about models. Telecom isn’t just about networks. The magic happens when you combine domains—like running AI simulations over a network stack to optimize latency in real time.

  1. Document & Share

Even if it’s messy, put your experiments online. GitHub, DEV.to, LinkedIn/X. It builds your credibility, attracts collaborators, and opens doors.

  1. Keep Learning

Right now, I’m leveling up in Next.js, distributed systems, and AI-native programming concepts. The tools change, but the mindset—learning fast, building iteratively, thinking systemically—never does.

If you’re working on AI + telecom or ambitious tech systems, my advice is simple: start building, document everything, and never stop iterating.

I’d love to connect with engineers, researchers, and founders pushing the boundaries of AI and telecom. Follow my journey on DEV.to and X (@rotimi_M_albert) to see the experiments in real time.

Effective Project Meeting Management: Turning Time Waste into Value

“How many meetings today?”

“Five. The whole day is meetings.”

Familiar conversation?

In many development teams, meetings have become synonymous with time waste.
To the point where people say “I need to work after meetings end.”

But not all meetings are bad.

Effective meetings:

  • Complete decisions within 15 minutes
  • All attendees clearly understand action items
  • 100% of decisions executed within 2 weeks

Ineffective meetings:

  • Discuss but don’t decide
  • Attendees understand differently
  • Meeting content not executed

What’s the difference?

This article presents concrete methods to make meetings a productive tool.

1. Clarifying Meeting Types and Purposes

1.1 Essential vs Optional Meetings

Not all meetings are necessary.

Essential meetings (Must do):

  • Daily Standup: 15 minutes daily, team synchronization
  • Sprint Planning: 2 hours biweekly, next sprint planning
  • Risk Review: 30 minutes weekly, risk identification and response
  • Stakeholder Update: 30 minutes biweekly, project status report

Optional meetings (Only when needed):

  • Design Review: When major design decisions needed
  • Technical Deep Dive: When discussing complex technical issues
  • Retrospective: Discuss improvements after sprint ends

Optimal meeting time by team size:

  • Small team (3-5 people): 5-6 hours/week
  • Medium team (6-10 people): 6-7 hours/week
  • Large team (10+ people): 7-8 hours/week

Recommendations:

  • Optimal meeting time: 5-7 hours/week (12-18% of total work)
  • 10+ hours/week: Excessive meetings (needs review)
  • Under 3 hours/week: Insufficient communication (risk)

2. Pre-Meeting Preparation

2.1 Agenda Writing Rules

Good agenda:

Meeting: Project Direction Decision
Purpose: Final technology stack selection
Time: 60 minutes

Agenda:
1. Compare 3 technology options (20 min) - Tech Lead
2. Discuss pros/cons of each option (20 min) - All
3. Final selection and decision (10 min) - PM
4. Next steps (10 min) - PM

Pre-meeting preparation:
- Share option comparison materials (24 hours before meeting)
- Collect pro/con opinions for each option (before meeting)

Bad agenda:

Meeting: Project Discussion
Purpose: (Unclear)
Time: 90 minutes

Agenda:
1. Recent progress (?)
2. Discuss issues (?)
3. Other (?)

2.2 Pre-Meeting Material Sharing

Pre-meeting preparation determines meeting efficiency.

Preparation checklist:

  • Agenda document (3 days before): Purpose, agenda items, time allocation, pre-reading materials
  • Background materials (2 days before): Previous decision documents, technical specs, comparison matrix
  • WBS draft (2 days before): For planning meetings, use AI to auto-generate task breakdown from specs — the team reviews instead of building from scratch
  • Individual preparation (1 day before): 30 minutes material review per attendee
  • Decision framework (2 days before): Specify method (vote, consensus, PM decision, etc.)

Effect of preparation:

For 60-minute meeting, 8 attendees:

  • Preparation time: ~20 hours (2 days × 8 hours + 8 people × 30 min)
  • Meeting time saved: 18 minutes (30% reduction)
  • ROI: 1:0.9 (Preparation takes slightly more time, but meeting quality improves)

Actually, preparation makes meetings deeper and decisions faster.

3. Efficient Meeting Management

3.1 Meeting Facilitation Rules

Rule 1: Strict Time Adherence

Meeting time: Exactly 60 minutes
- 0-2 min: No waiting for late attendees, start on time
- 58 min: Final conclusion
- 60 min: End on time (end even if boring)

Effect:
- 98% time adherence (vs 60% exceeding end time)
- 40% faster meeting pace
- Attendees more focused

Rule 2: Single Decision Maker

Every meeting must have a clear final decision maker

Example:
"PM makes final decision in tech stack meeting"
"Lead designer makes final decision in design review meeting"

Effect:
- 80% reduction in decision time
- Better decision quality (one person responsible)
- Reduced aftermath

Rule 3: No Laptops

No laptop/phone use during meetings

Reason:
- Brain focus improves 37%
- Note-taking promotes deeper understanding
- Prevents context switching

Exception:
- Note-taker (one person only)
- When live screen sharing needed

3.2 Meeting Structure

Decision meeting (60 minutes):

  • 0-2 min: Introduction and rules
  • 2-5 min: Context setting (Why are we here?)
  • 5-25 min: Present options and Q&A (20 min)
  • 25-50 min: Discussion and pros/cons (25 min)
  • 50-58 min: Decision (8 min)
  • 58-60 min: Action items and wrap-up (2 min)

Status update meeting (60 minutes):

  • 0-2 min: Agenda summary
  • 2-40 min: Feature-by-feature status update (38 min, 5 min each)
  • 40-55 min: Issues and blockers (15 min)
  • 55-60 min: Action items and wrap-up (5 min)

Daily standup (15 minutes):

  • 0-1 min: Quick introduction
  • 1-13 min: Per-person update (2-3 min)
  • 13-14 min: Record blockers
  • 14-15 min: Wrap-up

Meeting efficiency score:

  • 80+ points: Excellent (maintain current approach)
  • 60-80 points: Good (minor improvement needed)
  • Under 60 points: Poor (meeting format needs restructuring)

4. Post-Meeting Execution

4.1 Action Item Management

Effective action items:

  • Title: Specific and clear
  • Owner: Assign to one person only
  • Deadline: Specific date and time
  • Completion criteria: Criteria to verify how it’s completed

Priority determination:

  • Within 3 days: CRITICAL
  • Within 7 days: HIGH
  • Others: NORMAL

Actual tracking example:

Meeting: Technology Selection (Mon 10:00)
Action items:
1. [Dev Lead] Implement POC of selected technology - By Wed 17:00
   - Completion criteria: Sample code, 3 functions implemented
2. [PM] Request executive approval - By Tue 17:00
   - Completion criteria: Email approval obtained

Tracking:
- Mon 18:00: Lead sends "Need help" message
- Tue 09:00: PM updates status to "In progress"
- Wed 16:00: Lead marks "Complete"
- Thu 09:00: PM confirms approval email

4.2 Decision Documentation

Decisions must be documented after meetings.

Decision document structure:

  1. Basic info: Problem definition, option comparison
  2. Decision: Selected option, reason, decision maker, decision time
  3. Impact: Technical impact, schedule, budget, team
  4. Action items: Owner, deadline, completion criteria
  5. Rollback plan: Cost, time, conditions

Effect of documentation:

  • Decisions become clear
  • Can track why decisions were made later
  • New team members can understand
  • Decision quality improves

5. Meeting Alternatives

5.1 Async Communication

Not everything needs to be solved in meetings.

Meeting vs Async comparison:

Decision:

  • Meeting: 60 min, immediate decision, must be present, many interruptions
  • Async: 30 min (writing+review), decision within 24 hours, free participation, no interruptions
  • Recommendation: Async (for non-complex decisions)

Urgent issue:

  • Meeting: 30 min, immediate resolution
  • Async: Not possible
  • Recommendation: Meeting

Complex discussion:

  • Meeting: 90 min, limited depth, incomplete records, speaking rights competition
  • Async: 3-5 days, very deep, perfect records, everyone gets speaking opportunity
  • Recommendation: Async

Recommendations by scenario:

  • Urgent blocker/live crisis: Meeting (immediate)
  • Simple status/info sharing: Async (email/wiki)
  • Decision with options/design review: Async first, meeting if needed
  • Complex discussion/brainstorming: Develop ideas async first, finalize in meeting

5.2 Document-Based Decision Making

# Technology Stack Selection - Async Decision Process

## Step 1: Present Options (Tech Lead → Team)

- Document: Compare 3 technology options (pros, cons, cost, schedule)
- Channel: Share on Confluence
- Time: Tuesday morning

## Step 2: Collect Feedback (All Team Members)

- Each independently submits opinion
- Channel: Google Form or comments
- Time: Tue-Thu

## Step 3: PM Decision (PM)

- Review all feedback
- Write decision document
- Time: Friday

## Results:

- Decision time: 3 days (1/3 of meeting time)
- Participation: 100% (vs 70% for meetings)
- Documentation: Perfect (vs partial for meetings)

6. Meeting Evaluation and Improvement

6.1 Meeting Efficiency Measurement

Must measure if meetings are effective.

Meeting effectiveness metrics:

  1. Decision completion (30% weight): Whether decision completed after meeting
  2. Time efficiency (20% weight): Scheduled time / Actual time × 100
  3. Attendee quality (20% weight): Ratio of attendees needed for decision
  4. Action completion rate (20% weight): Ratio of action items completed by deadline
  5. Attendee satisfaction (10% weight): Post-meeting survey (1-5 points)

Score grades:

  • 85+ points: Excellent
  • 70-85 points: Good
  • 50-70 points: Needs improvement
  • Under 50 points: Poor

Improvement actions (when under 70 points):

  • Redefine meeting agenda
  • Review attendee list
  • Set time limits
  • Strengthen pre-meeting material preparation

6.2 Meeting Culture Improvement

Weekly Meeting Audit:

Mon: Evaluate last week's meetings
- Total meeting time: 12 hours (target: 8 hours)
- Decisions made: 5 (target: 6)
- Action item completion rate: 75% (target: 90%)

Issues identified:
- Decision delay (no decisions during meetings)
- 3 instances of essential attendee absence

Improvement measures:
- Strengthen "final decision maker" role
- Shorten pre-meeting material prep from 1 week to 3 days

Key Summary

Keys to effective meetings:

  1. Clear agenda → Faster progress
  2. Pre-meeting prep → Deeper discussion
  3. Strict time → Better focus
  4. Clear decision → Faster execution
  5. Documentation → Better tracking

Meetings aren’t evil, they’re tools.

Used correctly, they can double team productivity.
Used incorrectly, they waste time and decide nothing.

Apply these methods from your next project meeting.
Small changes make a big difference.

Need systematic project management with AI-powered task breakdown? Check out Plexo.