Shopify Ate the Enterprise Market And Barely Anyone Put Up a Fight

Shopify Ate the Enterprise Market — And Barely Anyone Put Up a Fight

What’s actually happening in enterprise e-commerce platforms in 2026, and why it matters more than you think

This is a rewrite of the original research by Paul Okhrem at Elogic Commerce: Enterprise Ecommerce Platform Market Share by Revenue Band

If you’ve been watching the enterprise tech market for a while, you know the usual script: Salesforce, Oracle, and SAP carve up the big corporate clients between themselves, scrappy challengers nibble at the edges, and everyone does their thing for years without much changing.

Something broke.

In 2024, the global e-commerce platform market hit $10.3 billion — up 14.2% year over year. That number alone isn’t the story. The story is who is driving that growth and how.

Shopify Wasn’t Supposed to Be Here. And Yet.

Three years ago, if you’d asked any enterprise architect whether Shopify would be competing with Adobe Commerce or Salesforce Commerce Cloud in major corporate RFPs, most would have laughed. “That’s the small business tool. For handmade candles and Etsy-adjacent stuff.”

Shopify now holds 22.7% of the e-commerce platform market — and that’s first place. Adobe (the artist formerly known as Magento), Salesforce, SAP, and BigCommerce follow behind. The top 10 vendors collectively control 70.5% of the total market — consolidated, but not monopolized.

What did Shopify get right? Dead-simple UX + a massive app ecosystem + relentless upmarket expansion through Shopify Plus. When you can spin up a store in a day and scale it to billions in transactions, the “but SAP is more reliable” argument starts losing steam fast.

The Market Is Growing, But the “Why Are We Paying This Much” Question Has Never Been Louder

Forecasts put the market somewhere between $45–66 billion by 2033, depending on which analyst firm you’re asking. CAGR in the 13–20% range over the next decade. Big numbers.

But here’s where it gets interesting: enterprise companies spent $18.7 billion on e-commerce platforms in 2024 alone — meaning just the enterprise slice is already larger than the entire market by some estimates. That’s because “platform market” means different things depending on the report: some count only licenses, others include the full stack of implementation, support, and customization.

One thing that tends to get buried in the press releases: 82.5% of the market is now cloud-based. On-premise isn’t dying slowly — it’s dying very slowly. Large corporations still cling to their data centers, but new projects almost universally go cloud-native.

Adobe and Salesforce Are Playing a Different Game — and Winning It

Yes, Shopify leads on market share. But Adobe Commerce and Salesforce Commerce Cloud are competing in a fundamentally different layer.

Adobe is doubling down on AI agents — systems that analyze user behavior in real time and automatically adapt content and personalization. Salesforce is weaving Commerce Cloud into its CDP and Marketing Cloud, essentially turning the platform into a unified nervous system for the entire customer journey.

This isn’t just marketing fluff. It’s a genuine bet that the future of enterprise commerce isn’t about launching fast — it’s about every customer getting a hyper-personalized experience in real time. That’s where Shopify is still catching up.

Composable Commerce Isn’t a Trend — It’s an Architectural Shift

Commercetools, VTEX, and other composable-first players are gaining serious traction in enterprise deals. The premise is straightforward: instead of a monolithic platform, you assemble a set of microservices like Lego.

More flexibility, yes — but also more complexity. Companies with strong engineering teams love the composable approach. Companies that need things to just work tend to land on Shopify Plus or BigCommerce.

VTEX is worth watching. They’re aggressively expanding beyond Latin America and positioning themselves as a credible option for global B2B/B2C operations. In a year or two, you’ll be hearing their name a lot more often.

What This Actually Means

A few takeaways worth keeping in mind — whether you’re a business picking a platform or an investor looking at the sector:

1. “Enterprise” no longer defaults to “expensive and complex.”
Shopify rewired expectations. Even large enterprise buyers now ask: “Why does this cost seven figures and take a year to implement when there’s an alternative that goes live in a month?”

2. AI is the differentiator, not a feature.
By 2025, nearly every platform had bolted on AI recommendations and basic automation. The real competition is now about how deeply AI is embedded into the actual business logic — and Adobe and Salesforce are ahead there.

3. Fashion & apparel own 38% of the market.
When people think “e-commerce platforms,” they imagine a universal tool. In reality, almost 40% of demand comes from fashion and apparel. That vertical’s quirks — SKU variants, seasonality, influencer integrations — quietly shape the product roadmaps of every major player.

4. Asia leads in volume; North America holds the margin.
Asia-Pacific accounts for 44.6% of global market revenue. But the most complex, highest-margin enterprise deployments are still concentrated in North America and Europe.

The Bottom Line

The enterprise e-commerce market isn’t just growing — it’s shifting in kind. The line between “SMB tool” and “enterprise solution” is dissolving. AI is moving from deck slides to actual business logic. Composable architecture is becoming the default for serious new builds.

Shopify won round one on reach and market share. Adobe and Salesforce are playing the long game — on complexity, deep integration, and the kind of sticky customers who are genuinely painful to migrate.

Who wins in five years? Probably both — just for different segments. But if Shopify keeps pushing upmarket at this pace… Adobe and Salesforce have real reasons to sweat.

Original research: Enterprise Ecommerce Platform Market Share by Revenue Band — Elogic Commerce by Paul Okhrem

Hardening a Linux Server in the Real World: Firewall, SSH, Fail2Ban, Nginx, Docker, .env Protection, and Bot Forensics

Every public server becomes part of the internet’s background noise very quickly.

That was not obvious to me in the same way until I started watching production traffic closely. I was not only seeing normal users, crawlers, and health checks. I was also seeing bots probing predictable paths:

/.env
/.env.production
/backup/.env
/wp/.env
/magento/.env
/api/v2/.env
/gateway/.env
/vendor/.env
/storage/.env
/.git/config
/credentials.json
/service-account.json
/__env.js
/actuator/env
/admin/phpinfo.php
/wp-admin/install.php

These were not random requests. They were patterns.

The same categories of paths appeared again and again, usually from new IP addresses, often through CDN ranges, and usually expecting one mistake: a leaked environment file, a forgotten backup, an exposed Git directory, a debug endpoint, a WordPress installer, a Spring actuator route, or a service account JSON file accidentally placed under the web root.

That experience changed how I think about server hardening.

I do not treat security as one tool anymore. I treat it as layers:

  • firewall first
  • SSH exposure reduction
  • key-only authentication
  • non-root users
  • Fail2Ban for behavior-based blocking
  • Nginx deny rules and allow lists
  • Docker isolation
  • process and resource monitoring
  • CDN and WAF in front
  • forensic habits when something looks wrong
  • secret isolation, especially around .env

This article is a practical write-up of how I harden Linux servers based on the real traffic I monitored and handled.

I also built a small Go project for this workflow: WatchTower-Sentinel.

GitHub: https://github.com/amirsefati/WatchTower-Sentinel

It tails Nginx access logs, tracks first-seen client IPs, watches CPU/RAM pressure, inspects suspicious processes, and sends concise Telegram alerts. In my case, it helped me identify real bot behavior and extract request patterns from production-like traffic instead of guessing from theory.

The first rule: assume your server is already being scanned

A fresh public IP is not invisible.

Once a service is reachable from the internet, it will eventually receive probes. Some are harmless crawlers. Some are noisy automated scanners. Some are looking for one specific mistake.

The most common mistake I saw in logs was not a complex exploit. It was a simple file exposure attempt:

GET /.env
GET /.env.production
GET /backup/.env
GET /wp/.env
GET /storage/.env
GET /credentials.json
GET /service-account.json
GET /.git/config

The attacker does not need a zero-day if the application serves secrets as static files.

That is why my hardening starts with boring basics. Boring security is usually the security that actually works.

Step 1: create a normal user and stop working as root

The first thing I do on a server is create a non-root user.

adduser deploy
usermod -aG sudo deploy

Then I copy my SSH key:

mkdir -p /home/deploy/.ssh
nano /home/deploy/.ssh/authorized_keys

chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

After that, I test login in a second terminal before touching root SSH access:

ssh deploy@SERVER_IP

Only after I confirm that the normal user works, I reduce root exposure.

Security is not only about blocking attackers. It is also about avoiding self-inflicted downtime. Never close the old door before testing the new one.

Step 2: harden SSH before enabling aggressive firewall rules

I usually edit:

sudo nano /etc/ssh/sshd_config

These are the important settings:

Port 2222
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
X11Forwarding no
AllowUsers deploy

Then I validate the config:

sudo sshd -t

If validation passes:

sudo systemctl reload ssh

Then I test the new port from another terminal:

ssh -p 2222 deploy@SERVER_IP

Only after that do I close the old SSH port at the firewall level.

Changing the SSH port is not real authentication security by itself. It does not replace keys. But it reduces the volume of automated noise hitting port 22, and that matters because clean logs are easier to investigate.

The real security improvement is:

root login disabled
password login disabled
only specific users allowed
key-based authentication required

Step 3: enable UFW carefully

The easiest way to lock yourself out of a server is to enable a firewall before allowing SSH.

So I always allow the new SSH port first:

sudo ufw default deny incoming
sudo ufw default allow outgoing

sudo ufw allow 2222/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Then enable:

sudo ufw enable
sudo ufw status verbose

If I know my own static IP, I prefer to restrict SSH even more:

sudo ufw delete allow 2222/tcp
sudo ufw allow from YOUR_PUBLIC_IP to any port 2222 proto tcp

This is much better than exposing SSH to the entire internet.

For production servers, I do not like leaving management ports open globally. SSH should be reachable only from trusted IPs, a VPN, a bastion host, or a private network whenever possible.

Step 4: install Fail2Ban for SSH and Nginx behavior

Firewall rules are static. Fail2Ban adds behavior.

Install it:

sudo apt update
sudo apt install fail2ban -y
sudo systemctl enable --now fail2ban

Create a local jail file:

sudo nano /etc/fail2ban/jail.local

For SSH:

[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
findtime = 10m
bantime = 1h
backend = systemd

Then restart:

sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd

Fail2Ban is not only for SSH. It becomes more useful when I add Nginx patterns for real traffic I see.

For example, I saw repeated sensitive-path probes like:

/.env
/.env.production
/.git/config
/credentials.json
/service-account.json
/actuator/env
/admin/phpinfo.php

So I can create a filter:

sudo nano /etc/fail2ban/filter.d/nginx-sensitive-paths.conf

Example pattern:

[Definition]
failregex = ^<HOST> - .* "(GET|POST|HEAD) /(.*)?(.env|.git/config|credentials.json|service-account.json|__env.js|actuator/env|phpinfo.php|wp-admin/install.php).*" (403|404|444) .*
ignoreregex =

Then add a jail:

[nginx-sensitive-paths]
enabled = true
port = http,https
filter = nginx-sensitive-paths
logpath = /var/log/nginx/access.log
maxretry = 3
findtime = 10m
bantime = 6h

Before trusting a custom filter, I test it:

sudo fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-sensitive-paths.conf

This part is important. A bad regex can either miss real attacks or ban normal users. I prefer starting strict, observing, and then tuning.

My rule is simple: Fail2Ban should block behavior, not curiosity. One weird request may be noise. Repeated sensitive path probing is a pattern.

Step 5: make Nginx reject sensitive files before the application sees them

The application should not be responsible for every bad request.

If a request is obviously targeting secrets, Git metadata, backups, or internal files, Nginx can reject it immediately.

A basic hardening snippet:

# Block hidden files such as .env, .git, .htaccess
location ~ /.(?!well-known) {
    deny all;
    access_log off;
    log_not_found off;
}

# Block common secret and config file names
location ~* ^/(.*)?(.env|.env..*|credentials.json|service-account.json|__env.js|composer.(json|lock)|package-lock.json|yarn.lock)$ {
    deny all;
    access_log /var/log/nginx/security-access.log;
}

# Block backup/archive/database dump files
location ~* .(bak|backup|old|orig|save|swp|sql|sqlite|db|tar|gz|zip|7z|rar)$ {
    deny all;
    access_log /var/log/nginx/security-access.log;
}

# Block obvious PHP probing on non-PHP apps
location ~* /(phpinfo.php|wp-admin/install.php|xmlrpc.php)$ {
    return 404;
}

For some routes, I use allow lists.

For example, if an admin panel must only be accessible from office/VPN IPs:

location /admin/ {
    allow YOUR_TRUSTED_IP;
    deny all;

    proxy_pass http://127.0.0.1:3050;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

If the application is behind Cloudflare or another CDN, the real client IP must be restored correctly. Otherwise Nginx and Fail2Ban may only see the CDN proxy IP. That makes banning dangerous because you might ban a proxy instead of the attacker.

In that case, configure the real IP module with trusted CDN ranges and use the correct header, for example:

real_ip_header CF-Connecting-IP;
set_real_ip_from CLOUDFLARE_IP_RANGE;

The exact ranges must be kept updated from the CDN provider.

The .env file deserves its own section

The .env file is one of the most targeted files on the internet.

That is because it often contains exactly what attackers want:

DATABASE_URL
REDIS_URL
JWT_SECRET
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
DIGITALOCEAN_SPACES_KEY
STRIPE_SECRET_KEY
SMTP_PASSWORD
TELEGRAM_BOT_TOKEN
GOOGLE_SERVICE_ACCOUNT_JSON
SENTRY_DSN

A leaked .env can turn a simple HTTP misconfiguration into a full infrastructure incident.

The biggest problem is that .env files are convenient during development, so teams sometimes treat them casually. But in production, .env is not just a config file. It is a secret boundary.

Here is how I handle it.

1. Never place .env under the public web root

This is the most important rule.

Bad idea:

/var/www/app/public/.env
/var/www/html/.env
/usr/share/nginx/html/.env

Better:

/opt/myapp/.env
/etc/myapp/myapp.env
/home/deploy/apps/myapp/.env

The file should exist outside any directory that Nginx can serve as static content.

2. Use strict permissions

For a single application user:

sudo chown deploy:deploy /opt/myapp/.env
sudo chmod 600 /opt/myapp/.env

That means only the owner can read and write it.

For a systemd service, I prefer an environment file:

[Service]
User=deploy
Group=deploy
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js

Then:

sudo chown root:deploy /etc/myapp/myapp.env
sudo chmod 640 /etc/myapp/myapp.env

This allows the service group to read it while preventing random users from reading secrets.

3. Never commit .env

My .gitignore always includes:

.env
.env.*
!.env.example

And .env.example must contain only safe placeholders:

DATABASE_URL=postgres://user:password@localhost:5432/app
JWT_SECRET=change-me
TELEGRAM_BOT_TOKEN=change-me

The example file documents required variables without leaking real values.

4. Do not bake secrets into Docker images

This is a common mistake.

Bad:

ENV DATABASE_URL=postgres://real-secret
COPY .env /app/.env

Better:

services:
  api:
    image: my-api:latest
    env_file:
      - /etc/myapp/myapp.env

Even better in orchestrated environments: use secret managers, platform secrets, Docker secrets, Kubernetes Secrets, or a cloud secret manager.

The image should be portable. Secrets should be injected at runtime.

5. Rotate secrets after exposure

If .env was exposed, removing the file is not enough.

I rotate:

database passwords
API keys
cloud access keys
JWT secrets
SMTP credentials
bot tokens
webhook secrets
object storage keys
third-party service tokens

Then I check logs for suspicious access during the exposure window.

A leaked secret must be treated as used, not just viewed.

Docker: do not casually run everything as root

Docker is not a magic sandbox.

If a process runs as root inside a container, it is still a risk. The level of risk depends on the runtime, capabilities, mounts, namespaces, and daemon configuration, but I avoid unnecessary root containers.

In Dockerfiles, I prefer:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

CMD ["node", "server.js"]

In Compose:

services:
  api:
    build: .
    user: "10001:10001"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp
    ports:
      - "127.0.0.1:3000:3000"

Important habits:

do not mount / unnecessarily
do not mount docker.sock into random containers
drop Linux capabilities when possible
use read-only filesystems where possible
bind services to 127.0.0.1 behind Nginx
avoid privileged: true unless there is a very strong reason

If I need stronger isolation, I look at rootless Docker, user namespaces, seccomp, AppArmor, SELinux, or moving the workload to a more controlled orchestrated environment.

The main idea is simple: if the app is compromised, the attacker should hit walls immediately.

Process limits and resource protection

Security is also availability.

A compromised app, a miner, or a broken process can consume CPU, RAM, file descriptors, or process slots.

For systemd services, I use limits like:

[Service]
User=deploy
Group=deploy
Restart=always
RestartSec=5

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

MemoryMax=500M
CPUQuota=80%
TasksMax=200
LimitNOFILE=65535

For Docker Compose:

services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

Depending on the environment, Compose resource limits may behave differently, so I always verify on the target host.

I also monitor:

top
htop
ps aux --sort=-%cpu | head
ps aux --sort=-%mem | head
systemctl status SERVICE_NAME
journalctl -u SERVICE_NAME -f

And for network activity:

ss -tunap
sudo lsof -i -P -n

This is where WatchTower-Sentinel helped me. Instead of manually checking all the time, I wanted a small sentinel that could detect first-seen IPs, suspicious request paths, high CPU/RAM pressure, and risky process activity, then send compact Telegram alerts.

Detecting miner-like infections and suspicious processes

When I suspect a miner or unwanted process, I do not start by deleting random files.

I first preserve enough information to understand what happened.

My quick triage flow:

uptime
top
ps aux --sort=-%cpu | head -30
ps aux --sort=-%mem | head -30

Then I inspect suspicious processes:

readlink -f /proc/PID/exe
tr '' ' ' < /proc/PID/cmdline
ls -la /proc/PID/fd
cat /proc/PID/environ 2>/dev/null | tr '' 'n'

Network connections:

ss -tunap
sudo lsof -i -P -n

Recently changed files:

sudo find /tmp /var/tmp /dev/shm -type f -mtime -2 -ls 2>/dev/null
sudo find /etc/systemd /etc/cron* /var/spool/cron -type f -mtime -7 -ls 2>/dev/null

Persistence checks:

crontab -l
sudo ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily
systemctl list-timers
systemctl list-units --type=service --state=running

Logs:

sudo journalctl --since "24 hours ago"
sudo grep -i "failed password" /var/log/auth.log
sudo grep -i "accepted" /var/log/auth.log

Common miner red flags:

high CPU with unknown binary
process running from /tmp, /var/tmp, or /dev/shm
weird random process names
outbound connections to unknown IPs
cron jobs that download shell scripts
systemd services with suspicious ExecStart
unexpected SSH keys added to authorized_keys

When I handled suspicious cases, I treated it like forensics first and cleanup second.

The professional approach is:

identify the process
identify how it started
identify persistence
identify network connections
identify modified files
rotate secrets
patch the entry point
rebuild if trust is lost

If the server is seriously compromised, I do not pretend that deleting one process is enough. I rebuild from a clean image, restore trusted data, rotate credentials, and close the original entry point.

That is the difference between “killing a miner” and actually fixing the incident.

CDN and WAF: why I prefer putting apps behind a protective layer

A CDN is not just for performance.

For public apps, I prefer having a CDN or reverse proxy layer in front because it gives me:

TLS termination
DDoS absorption
bot filtering
WAF managed rules
rate limiting
country/IP rules
header normalization
origin hiding

A WAF is especially useful for common attack classes:

path traversal
SQL injection patterns
XSS probes
known CMS exploit paths
suspicious user agents
automated scanners

But I do not rely on WAF alone.

The origin server must still be hardened.

If the origin IP is exposed and accepts traffic directly, attackers can bypass the CDN. So I restrict the origin to CDN IP ranges where possible, or I put the app behind private networking and only expose the proxy.

A good pattern is:

Internet
  -> CDN / WAF
    -> Nginx
      -> local app on 127.0.0.1
        -> private database/cache

Not:

Internet
  -> Node.js app directly
  -> database accidentally exposed

Nginx security habits that helped me

Here are Nginx patterns I use often.

Hide server tokens

server_tokens off;

Limit request body size

client_max_body_size 10m;

Basic rate limiting

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=api_limit burst=20 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }
}

Security headers

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

For HSTS, I only enable it when I am sure HTTPS is fully correct:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

Deny direct access to internal paths

location ~* ^/(internal|private|backup|storage|vendor)/ {
    deny all;
}

Return 444 for obvious garbage

Sometimes I use:

return 444;

for abusive traffic. It closes the connection without a response. I use it carefully because normal debugging becomes harder if overused.

How WatchTower-Sentinel helped me see patterns

I built WatchTower-Sentinel because I wanted lightweight visibility without deploying a heavy SIEM for every small server.

The idea is simple:

tail Nginx access logs
detect new client IPs
detect request bursts
detect sensitive path scans
watch CPU/RAM pressure
inspect suspicious processes
send compact Telegram alerts

In my Telegram reports, I could see events like:

SENSITIVE_PATH_SCAN
path=/.env
status=404

or:

NEW_IP
path=/credentials.json
status=404

or:

NEW_IP
path=/actuator/env
status=404

This changed the conversation from “maybe bots are scanning us” to “these are the exact paths they are probing.”

That is a big difference.

Once I had the patterns, I could turn them into:

Nginx deny rules
Fail2Ban filters
WAF rules
alert categories
incident review notes

That feedback loop is the most valuable part:

observe -> classify -> block -> monitor -> tune

Security improves when the server teaches you what is happening.

My practical hardening checklist

This is the checklist I like to apply before I trust a server:

Create non-root sudo user
Install SSH key
Disable root SSH login
Disable password SSH login
Move SSH to a non-default port
Allow SSH only from trusted IPs if possible
Enable UFW with default deny incoming
Allow only required ports
Install and configure Fail2Ban
Add custom Nginx filters for sensitive path scans
Block hidden files and secret files in Nginx
Keep .env outside public web roots
Set .env permissions to 600 or 640
Never commit .env
Never bake secrets into Docker images
Run app containers as non-root
Drop Docker capabilities
Avoid privileged containers
Bind internal services to 127.0.0.1
Put production apps behind CDN/WAF
Restrict origin access where possible
Monitor CPU/RAM/process/network behavior
Check cron/systemd persistence during incidents
Rotate secrets after any suspected exposure
Rebuild compromised servers when trust is lost

None of these steps are exotic. But together, they make a huge difference.

Final thoughts

The internet constantly tests basic mistakes.

Most of the traffic I observed was not sophisticated. It was automated, repetitive, and opportunistic.

But that is exactly why basic hardening matters.

If a bot requests /.env and receives 404 or 403, that is good.

If Nginx blocks it before the app sees it, better.

If Fail2Ban detects repeated probes and bans the source, better.

If the origin is behind a CDN/WAF and SSH is restricted, better.

If secrets are outside the web root, permissioned correctly, never committed, and rotated after exposure, much better.

My biggest lesson from running and monitoring real servers is this:

Security is not one big tool. It is a set of small decisions that reduce blast radius.

That is how I approach Linux hardening now.

I start with the boring layers, I watch real traffic, I convert patterns into controls, and I keep improving the system based on what the internet is actually doing to it.

That is also why I built WatchTower-Sentinel.

Not because alerts are cool, but because visibility changes how you defend a server.

Repository:

https://github.com/amirsefati/WatchTower-Sentinel

References

GenCAD: Generating Editable Parametric CAD Models From Images

Most AI 3D generators hand you a mesh — a shell of triangles you can render and 3D-print, but barely edit. Open one in Fusion 360 or SolidWorks and you cannot change a fillet radius, move a hole, or adjust a sketch dimension. The design intent is gone; you are left with frozen surface geometry. For anyone building design automation, CAD plugins, or AI-assisted engineering tools, that limitation is the whole problem.

GenCAD, a research project on generative AI for computer-aided design, takes the harder route. Instead of generating geometry, it generates the program that builds the geometry — a sequence of parametric CAD operations you can re-open, re-run, and edit.

Why a mesh is not a CAD model

A mesh describes a surface: thousands of triangles pinned in 3D space. A parametric CAD model describes a process: draw a 2D sketch, extrude it 40 mm, cut a 6 mm hole, round an edge. Each step carries editable parameters, and the order of steps encodes design intent.

That difference decides what you can do next. Change “40 mm” to “55 mm” in a parametric model and the dependent geometry updates correctly. There is no equivalent edit on a mesh — you would be dragging vertices and hoping.

GenCAD outputs the process, not the surface. Its target representation is a CAD command sequence in the style of the DeepCAD dataset: 2D sketches composed of lines, arcs, and circles, followed by extrude operations. Because the result is a short program rather than a point cloud, it drops into a feature tree and stays editable.

How GenCAD generates a design

The published approach chains three components, and understanding them tells you where it will and will not fit your stack.

First, a CAD autoencoder. A transformer is trained to compress a CAD command sequence into a fixed-length latent vector and reconstruct it back into valid commands. This produces a continuous latent space where nearby points decode to buildable designs.

Second, contrastive image-to-CAD alignment. Borrowing the idea behind CLIP, an image encoder is trained so that a rendered picture of a part lands close to that part’s CAD latent. The model learns a shared space for what a part looks like and how the part is built.

Third, a latent diffusion model. Conditioned on an image embedding, it samples a CAD latent, and the autoencoder’s decoder turns that latent back into a command sequence. Image in, editable CAD program out.

GenCAD builds on the DeepCAD representation, a public dataset of over 170,000 CAD construction sequences sourced from Onshape. That foundation is also a ceiling: the representation covers sketch-and-extrude modeling. Lofts, sweeps, revolves, and freeform surfacing sit outside its vocabulary, so the model reasons about prismatic, mechanical-looking parts rather than organic shapes.

What you can build on it today

GenCAD is research code with a paper, not a hosted API or an SDK. If you are scoping it into a product, four constraints matter.

The output needs a translation layer. A DeepCAD-style command sequence is not a STEP file. The sketch-and-extrude operations map cleanly onto the Onshape FeatureScript or Fusion 360 API, but you write that bridge yourself.

The operation vocabulary is narrow. Brackets, plates, housings, and simple mechanical parts are in scope. Turbine blades and ergonomic grips are not.

It is image-conditioned. GenCAD’s published work generates CAD from an input image. Text-to-CAD — typing a prompt and getting a model — is a parallel research thread, not what this project does. If you need natural-language input, you are building that front end or pairing GenCAD with a separate text-to-image step.

Reproducing it means training models. You need the dataset, GPU time, and the patience to retrain. Treat GenCAD as a reference architecture for your own system, not a dependency you import.

Generated CAD can be geometrically valid and still wrong. A model may close cleanly in a kernel yet violate wall-thickness rules, miss a tolerance, or simply not match the request. Any pipeline that turns AI output into manufacturable parts needs a validation stage — geometry checks, design-rule checks, and human review before anything reaches a machine.

If you want to clone the GenCAD repository and trace how the autoencoder, contrastive encoder, and diffusion model fit together, an AI-aware editor shortens the loop. It can summarize an unfamiliar training script, explain a tensor-shape mismatch, and help draft the export bridge to your CAD kernel.

For a developer audience, the takeaway is structural. The notable move in GenCAD is not the diffusion model — it is the decision to generate an editable CAD program instead of dead geometry. Any team building AI into an engineering workflow faces the same choice, and the parametric path is the one that survives contact with a real design review.

Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

The Public Code Behind xAI’s “For You” Feed

Turkish version: blog.arasmehmet.com

Disclaimer: This post is based on the public
xai-org/x-algorithm repository. It describes what the
released code shows and where that public release stops.

Open X, scroll “For You,” and the simple picture is easy: posts go in, the best-looking ones rise.

Look at the repo, and that picture changes. “For You” does not look like a place that only scores posts and sorts them. The code shows viewer guesses, post judgments, reply scores, and items placed into the list after ranking.

That is the interesting shift: the story is not just ranking logic. It is the information carried around the feed.

Not gender knowledge. A gender guess.

The code does not say “X knows your gender.” It shows something narrower: a gender-guess step in the Phoenix feed path. Not certainty. A label and a confidence score.

That matters because the value is not about the post. It travels with the request for the person asking for the feed.

The step is not always active. When it runs, a new account gets a fresh prediction if there is no saved result. For an older account, this file only uses the saved guess. Nearby, there is also a slot for demographics. The repo shows the slot, not the full contents.

A viewer signal this close to the feed does not look like decoration. The repo does not show the final use. But it does show the kind of information the feed path is prepared to carry.

Posts carry judgment fields too

The post side is easier to see.

Grox returns fields like quality score, description, tags, topic categories, whether an image is editable by Grok, slop score, and minor-related score.

The names matter. They do not read like private machine internals. They read like judgments attached to a post.

This is where “content understanding” turns into data on the post. The exact prompt Grox receives is not in the repo. The output fields are visible.

Replies get scored too

Replies have their own Grox scorer.

Score: 0, 1, 2, 3. The scorer looks at the surrounding conversation, not just the reply as a standalone sentence.

That does not tell us where the score appears in the product. But it changes the picture: a reply is not only a comment under a post. It is also content to evaluate.

The feed is not just ranked. It is arranged.

A ranked post list is only part of it. The code shows items from other sources being placed into that list.

The For You code lists ads, Who to Follow suggestions, prompts, and push-to-home posts as separate sources. Prompts and Who to Follow suggestions are inserted into the list. A push-to-home item is placed at the very top when present.

The push-to-home path is the cleanest example. When the request names a tweet for push-to-home, the code builds a special feed item for it. If that tweet starts a conversation, the code attaches up to three top repliers.

Ads and prompts come with context too. Ads receive fields like country, language, IP address, user agent, user roles, device id, mobile device id, and mobile ad id. Prompts receive user and device context plus the prompt types supported by the app.

So the feed is not just a scoreboard. It is scored posts plus inserted modules, special items, and context passed to other systems.

What the public repo leaves out

The public repo is useful because it shows the rough shape of the system. It also shows where it stops.

The clearest gap is home-mixer. The code references a shared helper module 18 times across 16 Rust files, including the part that builds the Phoenix request. But that module is not in the released tree. The simple result: home-mixer does not compile as-is.

This does not look like a tiny missing detail. The feed code leans on a shared helper layer, and that layer is not visible in the repo.

Grox has similar edges. Some files refer to prompt, config, or model folders; those folders are not in the repo. Phoenix shows the same pattern: the docs explain how to run a system, but the code needed to run it is not visible in the repo.

So it is better to think about the repo in two parts: what it shows, and what it leaves out. The visible part gives serious clues about the feed. The missing part stops us from proving exactly how everything connects in the live system.

Closing

The repo’s real message is this: “For You” does not just pick posts. It carries information.

That information includes viewer guesses, post judgments, reply scores, inserted feed items, and context for ads and prompts. But the repo does not show exactly how all of those pieces are used in the live system.

That is the difference: “For You” looks less like a single ranking rule and more like a wider system carrying context around the feed.

Get in touch: arasmehmet.com

Where this lives in the code

Release frame

  • release note: README.md:L26-L42
  • Grox release description: README.md:L34
  • Phoenix ranking summary: README.md:L53-L56
  • public-release boundary examples:
    • home-mixer/lib.rs:L1-L13
    • grox/classifiers/content/banger_initial_screen.py:L8-L13
    • phoenix/README.md:L210-L238

Viewer context

  • inferred-gender behavior:
    • home-mixer/query_hydrators/user_inferred_gender_query_hydrator.rs:L30-L47
    • “new user” cutoff: home-mixer/query_hydrators/user_inferred_gender_query_hydrator.rs:L56
  • demographics behavior:
    • home-mixer/query_hydrators/user_demographics_query_hydrator.rs:L14-L24
    • query fields: home-mixer/models/query.rs:L72-L77
  • connected in Phoenix:
    • modules declared: home-mixer/query_hydrators/mod.rs:L17-L18
    • added to Phoenix query steps: home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs:L228-L231

Post judgments

  • Grox result fields:
    • grox/classifiers/content/banger_initial_screen.py:L30-L38
    • parsed into category results: grox/classifiers/content/banger_initial_screen.py:L102-L155
  • connected in Grox:
    • stream includes this check: grox/generators/stream_generator.py:L71-L78
    • plan for this check: grox/plans/plan_initial_banger.py:L14-L37
    • main plan includes it: grox/plans/plan_master.py:L18-L34
    • engine starts the main plan: grox/engine.py:L51-L55
  • saved output:
    • metrics and fields: grox/tasks/task_pub.py:L166-L219, grox/tasks/task_pub.py:L308-L325
    • write call: grox/tasks/task_pub.py:L339-L340
  • missing prompt body:
    • grox/classifiers/content/banger_initial_screen.py:L13
    • no grox/prompts/ directory in this checkout

Reply scoring

  • reply scorer:
    • grox/classifiers/content/reply_ranking.py:L29-L49
    • thread view and signals: grox/classifiers/content/reply_ranking.py:L53-L72
    • fallback sampling: grox/classifiers/content/reply_ranking.py:L74-L88
    • score cleanup: grox/classifiers/content/reply_ranking.py:L103-L158
  • connected in Grox:
    • grox/tasks/task_rank_replies.py:L11-L31
    • stream eligibility: grox/generators/stream_generator.py:L58-L68
    • recovery stream: grox/generators/stream_generator.py:L173-L178
    • plan: grox/plans/plan_reply_ranking.py:L10-L27
    • plan master: grox/plans/plan_master.py:L18-L34

Feed assembly

  • feed sources:
    • home-mixer/candidate_pipeline/for_you_candidate_pipeline.rs:L164-L177
    • selector constructed: home-mixer/candidate_pipeline/for_you_candidate_pipeline.rs:L179-L201
  • mixing and insertion:
    • home-mixer/selectors/blender_selector.rs:L30-L51
    • prompts: home-mixer/selectors/blender_selector.rs:L77-L85
    • Who to Follow: home-mixer/selectors/blender_selector.rs:L89-L100
    • push-to-home pinned at top: home-mixer/selectors/blender_selector.rs:L103-L113
  • push-to-home source:
    • home-mixer/sources/push_to_home_source.rs:L19-L67
    • top repliers: home-mixer/sources/push_to_home_source.rs:L70-L95
  • ad and prompt context:
    • home-mixer/sources/ads_source.rs:L41-L57
    • home-mixer/sources/prompts_source.rs:L18-L46
    • home-mixer/sources/prompts_source.rs:L49-L98

Public boundaries

  • home-mixer missing shared module:

    • module list: home-mixer/lib.rs:L1-L13
    • rg -n "crate::util" home-mixer --glob "*.rs" returns 18 hits across 16 Rust files
    • Phoenix request builder import: home-mixer/scorers/phoenix_scorer.rs:L8
    • Phoenix request builder call: home-mixer/scorers/phoenix_scorer.rs:L84
    • Phoenix request cache helpers: home-mixer/side_effects/phoenix_request_cache_side_effect.rs:L4-L6, home-mixer/side_effects/phoenix_request_cache_side_effect.rs:L77-L85
    • other examples pointing to missing crate::util: home-mixer/server.rs:L3-L9, home-mixer/scorers/ranking_scorer.rs:L3-L4, home-mixer/scorers/vm_ranker.rs:L1-L5, home-mixer/for_you_server.rs:L3-L5
  • Grox missing packages:
    • grox/classifiers/content/banger_initial_screen.py:L8-L13
    • grox/classifiers/content/reply_ranking.py:L9-L13
    • grox/classifiers/content/post_safety_screen_deluxe.py:L9-L14
    • no grox/prompts/, grox/config/, or grox/lm/ directory in this checkout
  • Phoenix README boundary:
    • phoenix/README.md:L210-L238
    • find phoenix -maxdepth 3 -type f returns only phoenix/README.md in this checkout

Stop Drowning in Console Noise: Using Negative Regex Filters in Chrome DevTools

TL;DR: The thing that breaks your focus isn’t the bug — it’s spending four minutes scrolling through console noise trying to find the bug. A real Next.

📖 Reading time: ~22 min

What’s in this article

  1. The Console Is Lying to You (By Omission)
  2. How the DevTools Console Filter Bar Actually Works
  3. Writing Your First Negative Filter
  4. Chrome DevTools: The Full Walkthrough
  5. Firefox DevTools: Same Idea, Slightly Different Behavior
  6. Practical Negative Filter Patterns I Actually Use
  7. Going Further: Console Filtering in Code (Not Just the DevTools UI)
  8. When Negative Filters Break or Behave Weirdly

The Console Is Lying to You (By Omission)

The thing that breaks your focus isn’t the bug — it’s spending four minutes scrolling through console noise trying to find the bug. A real Next.js app with Segment analytics, Intercom’s chat widget, and a lazy-loaded Google Maps SDK will dump somewhere between 150 and 300 console lines on a cold page load before your user has clicked a single thing. Your actual TypeError: Cannot read properties of undefined is sitting in there somewhere, sandwiched between Intercom’s heartbeat pings and whatever the map SDK decided to log about tile caching.

Here’s what that looks like in practice. Segment fires [Analytics] Page called on every route change. Intercom logs its own boot sequence — connection status, user identification, widget render. The Maps SDK throws deprecation warnings about APIs it’s using internally that you have zero control over. None of this is your code. None of it is actionable. But it all lands in the same console as the null reference error that’s crashing your checkout flow at a specific scroll depth on mobile Safari.

Negative regex filtering is the fix, and it’s one line. The DevTools filter bar at the top of the Console panel accepts regex. Prefix your pattern with a minus sign and DevTools hides every message that matches it. That’s the entire mechanic:

-/[Analytics]|[Intercom]|tile|deprecated/i

That single filter silences four categories of noise simultaneously. The - prefix is the key — without it, you’d be keeping only those matches instead of hiding them. The i flag makes it case-insensitive so you don’t have to think about whether Intercom logs [Intercom] or [intercom]. What’s left in your console is almost entirely your application code. I switched to doing this on every project after the third time I missed a real error because it rendered between two Segment events and I scrolled past it.

The gotcha nobody mentions: this filter doesn’t persist across DevTools sessions by default. Close the panel, reopen it, and you’re back to the firehose. There’s a workaround — Chrome’s DevTools has a “Save as” option for filter presets in newer builds, but it’s buried and inconsistent across versions. The practical solution most developers land on is keeping a project-specific filter string in a comment at the top of their .env.local or in a team wiki, so anyone onboarding can paste it immediately. For a broader look at cutting dev workflow friction, check out the Ultimate Productivity Guide: Automate Your Workflow in 2026.

How the DevTools Console Filter Bar Actually Works

The filter bar’s biggest trick is one that almost nobody discovers without actively searching for it: it has its own mini-language that looks like regex but isn’t quite. I spent two years using DevTools thinking the filter was just a dumb substring search before a coworker showed me you could prefix with a dash. That single character changed how I debug complex apps.

Plain text filtering is exactly what it sounds like — type firebase and the console shows only lines containing that string anywhere. Case-insensitive by default. Useful, but limited. The moment your app has 15 different Firebase operations logging simultaneously, “firebase” as a filter just shows you noise in a different shape.

Positive regex gets you the precision you actually need. Wrap your pattern in forward slashes and Chrome treats it as a regular expression:

/^[Analytics]/

That matches only lines where the message starts with [Analytics]. The anchoring matters — without the ^, it’ll still match [Analytics] anywhere in the string, which is fine if that’s what you want. You can use the full JavaScript regex syntax here: character classes, quantifiers, alternation with |, all of it. So /^[(Analytics|Tracking|Pixel)]/ works exactly as you’d expect to match three different prefixed log families at once.

The inversion operator is where people get confused, because it looks like it should be regex but it isn’t. To exclude lines matching a pattern, you prefix the whole filter expression with a dash:

-/[Analytics]/

That dash before the slash is Chrome’s own filter syntax — it is not a regex negative lookahead. This is the gotcha that trips everyone up. If you try the “correct” regex way and type /(?![Analytics])/ expecting to filter out analytics messages, it won’t work the way you think. The (?!...) lookahead is valid regex and Chrome will parse it, but it will match nearly everything because a zero-width assertion at the start of an arbitrary string almost always passes. The actual inversion mechanism is the leading dash, full stop. You can also use it with plain text: -firebase hides every line containing “firebase”. Same operator, same position, works on both plain strings and slash-delimited patterns.

One more practical detail: the filter applies to the entire logged line as Chrome renders it, not just the message string. That means it includes the filename and line number that appear on the right side of the console entry. This is occasionally useful — /auth.js/ will surface only messages originating from your auth module — but it also means a poorly written negative filter can accidentally hide things because a source file path matched. Worth keeping in mind when your filter seems to be dropping more lines than expected.

Writing Your First Negative Filter

The thing that surprises most developers the first time they try this: the filter bar in Chrome DevTools isn’t just a plain text search. It accepts actual regex, and the - prefix flips it into exclusion mode. So -/pattern/ means “hide any console message matching this regex.” One character prefix, massive quality-of-life improvement.

Start with a single noisy culprit. If Intercom is flooding your console with its connection lifecycle logs, type this directly into the filter bar:

-/[Intercom]/

The backslashes escape the square brackets because they’re regex special characters. Without escaping, [Intercom] would be interpreted as a character class matching any single letter in that set — you’d hide messages containing just “I”, “n”, “t”, etc. Easy mistake, and Chrome won’t warn you about it. Once you add that filter, every line prefixed with [Intercom] vanishes immediately. No page reload needed.

The real power shows up when you chain exclusions using the pipe operator inside a single regex group. One filter string can silence your entire analytics stack:

-/([Intercom]|[Segment]|gtm.js)/

The dot in gtm.js needs escaping too — unescaped, it matches any character, so gtmXjs would also get filtered. Pedantic, but regex is unforgiving here. In production React apps I’ve worked on, the filter string ends up looking more like this:

-/(Intercom|analytics.js|_hjSession|GTM-|[WDS]|webpack-dev-server)/

That one string kills Intercom noise, Segment’s analytics.js handshake messages, Hotjar session logs, Google Tag Manager identifiers, and Webpack Dev Server’s HMR chatter all at once. The [WDS] pattern specifically catches webpack’s hot reload notifications that appear constantly during development — completely useless information when you’re debugging a form submission bug.

Add the i flag when dealing with third-party SDKs that haven’t made up their minds about capitalization. Some SDKs log [Segment] in some versions and [segment] in others, or mix casing mid-message:

-/([intercom]|analytics)/i

The i flag goes after the closing slash, same position as in JavaScript regex literals. This matters most for older SDKs and anything that generates log messages dynamically from variable content — I’ve seen A/B testing tools that lowercase their own name in error logs but not in info logs. One filter with /i covers both instead of you having to discover the inconsistency the hard way.

Chrome DevTools: The Full Walkthrough

The filter input sitting at the top of the Console panel is deceptively powerful. Most devs type plain strings into it and call it a day — but it accepts full JavaScript-flavored regex when you prefix with a forward slash. That one detail changes everything about how you can suppress noise.

Finding the Filter Bar and Writing Your First Negative Regex

Open DevTools with F12 or Cmd+Option+I, click the Console tab, and you’ll see the input field at the top left with the placeholder text “Filter”. Click into it and paste something like this:

/-[HMR]|-[WDS]|Download the React DevTools/

That filters in — it only shows matching messages. To exclude messages instead, you need a negative lookahead wrapped around the whole thing:

/^(?!.*(HMR|webpack compiled|[WDS]|Download the React DevTools))/

The console updates live as you type — no Enter, no button click. This is actually useful for iterating on a noisy regex because you can watch messages appear and disappear in real time while you refine the pattern. The thing that caught me off guard the first time was that Chrome applies the filter against the full text content of the log entry, including the filename and line number shown on the right side. So if you have a noisy module at vendor.chunk.js:1, you can filter on that filename directly.

Stacking the Filter with Log Level Buttons

The Default / Verbose / Errors / Warnings / Info buttons in the toolbar are completely independent of the regex filter — they work as an AND condition, not OR. So if you set the level to “Errors only” and also have a regex filter active, Chrome shows messages that both match the error level and pass the regex. This is genuinely useful: I’ll often flip to Errors-only during a debugging session and still have my noise filter active so that a third-party script’s thrown-but-caught errors don’t crowd out mine.

/* Stack example:
   Level: Errors + Warnings (Default level, unchecked Verbose)
   Filter: /^(?!.*(stripe.js|intercom|gtm.js))/ 
   Result: only your own errors/warnings show up */

Use “Hide Network Messages” First

Before you reach for regex at all, look at the gear icon (Console Settings) inside the Console panel — there’s a Hide network messages checkbox. If a significant portion of your noise is fetch / XHR log entries, one checkbox click removes all of them instantly. Regex filtering against those entries is wasteful when this exists. I only pull out the regex when the noise is coming from console.log calls inside actual JS — analytics scripts, HMR feedback, framework internals, that kind of thing.

The Session Persistence Problem and the Practical Workaround

Chrome does not save your filter bar content when you close DevTools or reload the page. This is genuinely annoying on large projects. The official answer is basically “too bad”, but here are the two workarounds I actually use:

  • Project notes comment block: Keep a DEVTOOLS.md or a comment block at the top of your main dev entry file with your filters ready to copy-paste. Takes three seconds to re-apply.
  • DevTools Snippet: Open Sources → Snippets, create a new snippet, and have it call console.clear() plus set up any overrides you need. It won’t inject the filter bar text programmatically (Chrome doesn’t expose that API), but you can use it to suppress specific loggers by overriding them:
// Snippet: suppress_noise.js
// Run this from Sources > Snippets after page load
const originalLog = console.log.bind(console);
console.log = (...args) => {
  const msg = args.join(' ');
  // Drop HMR and analytics noise at the source
  if (/HMR|webpack compiled|_hjSettings/.test(msg)) return;
  originalLog(...args);
};

The snippet approach is more powerful than the filter bar because it actually prevents the messages from reaching the console at all — useful when you’re profiling and don’t want suppressed-but-still-evaluated log calls adding overhead. The filter bar approach keeps everything in the buffer and just hides it visually, which means heavy logging can still slow things down even when filtered out.

Firefox DevTools: Same Idea, Slightly Different Behavior

The thing that caught me off guard the first time I switched to Firefox for debugging was that my carefully crafted Chrome filter strings did absolutely nothing. No error, no warning — the filter just silently failed to match anything, and I spent a good ten minutes wondering why noise messages were still showing up.

Firefox’s console filter bar does support regex, but the negation operator is ! instead of Chrome’s -. In Chrome you write -/noise/ to hide messages matching that pattern. In Firefox 121+, the equivalent is !/noise/. Same concept, different character. The base regex syntax is otherwise identical — /pattern/ with standard JS regex rules inside.

Here’s a quick way to verify this actually works before you trust it in a real debugging session. Open a new tab, navigate to about:blank, open the console, and paste this:

// Simulate the noisy logging you'd see from analytics or a chatty library
setInterval(() => console.log('noise: heartbeat ping'), 800);
console.error('real error: API response was 500');
console.warn('real error: token expiry in 60s');

Now type !/noise/ into the filter bar. The interval logs vanish, the two real error lines stay. If you’re on Firefox older than 121, regex filtering in the console is inconsistent — upgrade or use the plain text filter as a fallback. The ! prefix works on the whole expression, so !/noise|heartbeat/ hides anything matching either term, which is exactly what you want when you’re hunting down one specific failure across a wall of telemetry output.

One genuinely useful behavior Firefox has that Chrome doesn’t: the filter bar content persists across page reloads within the same session. You set !/noise/, hit refresh to reproduce a bug, and your filter is still there. Chrome clears the filter on reload unless you’ve checked “Preserve log.” It’s a small thing but when you’re doing reload-heavy debugging it saves a surprising amount of re-typing.

The cross-browser gotcha to internalize: if you work in both browsers (and most of us do), build a muscle memory check before pasting filter strings. Chrome-format filters look like this:

-/analytics|tracking|noise/

Firefox-format is:

!/analytics|tracking|noise/

Pasting the Chrome version into Firefox doesn’t throw an error — it just treats - as a literal character and matches nothing useful. The failure mode is invisible, which makes it a genuinely annoying gotcha. If you share filter strings with teammates who use different browsers, the safest thing is to document both versions explicitly rather than assuming they’ll know to swap the prefix.

Practical Negative Filter Patterns I Actually Use

The filter that saved my sanity first was the HMR one. Running a Vite or CRA project means your console fills with connection status messages before your app even boots. None of those messages are actionable — they’re noise that buries the TypeError you’re actually looking for.

React / Vite / Webpack Dev Server Chatter

Paste this directly into the DevTools filter bar (the text input next to the log level dropdown in the Console tab):

-/([HMR]|[vite]|[WDS])/

This knocks out Vite’s “connected” / “page reload” messages, CRA’s Webpack Dev Server handshake logs, and HMR update confirmations. The thing that caught me off guard initially: the brackets in [HMR] are literal characters in the log string, not regex grouping syntax — so you do need to escape them with backslashes inside the filter pattern. Chrome’s console regex engine is a bit quirky about this.

Analytics and Tag Manager Spam

GTM fires a wall of debug output when preview mode is active, and every analytics SDK loves to announce itself on init. This pattern covers the common offenders:

-/(gtag|GTM-|google-analytics|_ga|fbq|analytics)/i

The /i flag matters here. fbq (Facebook Pixel) logs in lowercase, GTM container IDs come through as GTM-XXXXX uppercase. Without case-insensitive matching you’ll miss half of them. This also silences the GA4 debug extension if you happen to have it installed but aren’t actively debugging a tracking issue.

Third-Party Chat and Support Widget Noise

Every support widget thinks you want to know it loaded successfully. Intercom is particularly chatty — it logs its boot sequence, its messenger version, and sometimes individual event tracking calls:

-/(Intercom|Zendesk|Crisp|Drift|HubSpot)/i

I keep this one off by default and only enable it when I’m not actively debugging a support widget integration. The flip side: if Intercom isn’t loading for a user and you’re trying to diagnose it, you obviously want those messages back. Negative filters are a toggle, not a permanent delete — just clear the filter field and everything returns.

Sentry SDK Init Logs

Sentry’s SDK logs its DSN, release version, integration list, and sometimes individual breadcrumb captures depending on your config. All of it prefixed neatly:

-/([Sentry])/

One gotcha: if you’ve set debug: true in your Sentry.init() call — which is common in staging environments — you get a much higher volume of output. This filter cleans it all up without you having to touch the SDK config, which matters when staging and production share a codebase branch and you don’t want to commit a debug flag change.

Browser Extension Injected Messages

Extensions inject content scripts that log into your page’s console. This is especially disruptive when you’re demoing to someone and random Grammarly or LastPass messages appear mid-screen recording:

-/(chrome-extension|moz-extension)/

These messages always include the extension’s internal URI in the log origin or the message body, so this pattern catches them reliably. I’ve never seen a false positive from it — no legitimate app code logs a chrome-extension:// path.

The Kitchen Sink Filter for Legacy Projects

Legacy codebases are where filter discipline really pays off. You inherit a project running GTM, Segment, Intercom, Hotjar, Facebook Pixel, and a CRA dev server simultaneously. This is the one I reach for immediately:

-/(gtm|segment|intercom|hotjar|_hjSession|fbq|[WDS]|[HMR]|vite|sentry)/i

The _hjSession token specifically catches Hotjar’s session recording initialization, which otherwise dumps a multi-line JSON blob into your console every single page load. Bookmark this pattern somewhere — a sticky note in Notion, a comment in your .env.local, anywhere. DevTools doesn’t persist custom filters between sessions, so you’ll be pasting this repeatedly until muscle memory kicks in.

Going Further: Console Filtering in Code (Not Just the DevTools UI)

The negative regex filter in the DevTools UI resets every time you close the tab. If you’re spending the first five minutes of every debugging session re-entering -/Intercom|GTM|_hjSession/, that’s the moment to reach for a code-level solution instead.

Monkey-Patching console.log for Persistent Noise Suppression

The pattern is simple: save the original, wrap it, bail early on matches. Here’s the snippet I keep in a DevTools Snippet (Ctrl+Shift+P → “Create new snippet”) so I can run it on demand without touching the codebase:

const _log = console.log;
console.log = (...args) => {
  // bail early if any argument matches known noisy third-party libs
  if (/Intercom|GTM|_hjSession/.test(args.join(' '))) return;
  _log(...args);
};

You can extend this to console.warn and console.info using the same pattern — Intercom in particular loves to spam across multiple levels. The args.join(' ') call matters here: third-party libs often pass objects or multiple arguments rather than a single string, and joining them first means your regex fires against the full combined output rather than missing a match because it landed in arg index 2.

Why This Is a Last Resort, Not a Starting Point

The danger is regex drift. You write /GTM/ to suppress Tag Manager noise, then six months later your own analytics module also logs something with “GTM” in it and you wonder why production data looks wrong — except you never see the log because you suppressed it locally. I’ve been burned by this. The DevTools UI filter is safer because it’s visually obvious, session-scoped, and doesn’t touch the runtime. Use the UI filter first, use the code approach only when the UI filter genuinely can’t survive your workflow.

Using Chrome’s Overrides to Inject Without Touching Your Code

If you want persistence without modifying main.js or your bootstrap file, Chrome’s Sources → Overrides panel is the right tool. You map a local file to a network request, and Chrome serves your version instead. Here’s the exact flow:

  1. Open Sources panel → select the Overrides tab → click “Select folder for overrides” and grant permission to a local directory.
  2. In the Network panel, find your app’s entry JS file (usually main.[hash].js or similar), right-click it → Save for overrides.
  3. Chrome copies it to your local folder and opens it in the editor. Prepend the monkey-patch snippet at the top of that file.
  4. Reload. Chrome now serves your locally modified version on every reload — including after browser restarts — as long as Overrides is enabled.

The catch: hashed filenames. If your build pipeline rotates the hash on every deploy (main.a3f9c1.js → main.b72de4.js), your override becomes a dead file because Chrome matches by exact filename. This approach works better for apps in active local development where the filename is stable, or for overriding a third-party script at a fixed URL (like the Intercom widget itself). For CI-built apps with content hashing, you’re better off putting the dev-only bootstrap snippet inside an environment guard in your actual source:

// runs only when NODE_ENV=development
if (process.env.NODE_ENV === 'development') {
  const _log = console.log;
  console.log = (...args) => {
    if (/Intercom|GTM|_hjSession/.test(args.join(' '))) return;
    _log(...args);
  };
}

Tree-shaking will eliminate this block in production builds if you’re using Webpack 5+ or Vite, so the runtime cost is zero in prod. The process.env.NODE_ENV replacement happens at build time as a string literal, and both bundlers are smart enough to see the dead branch and drop it.

When Negative Filters Break or Behave Weirdly

The first gotcha that bites almost everyone: type -/./ in the filter bar and your console goes completely blank. That dot is a wildcard — it matches any single character, which means every log message matches, and the negative prefix hides all of them. You’re left staring at nothing, wondering if your app stopped logging entirely. The fix is obvious in hindsight but cost me ten minutes the first time.

Regex syntax errors are worse than they sound in DevTools. Chrome turns the filter input red when it can’t parse your pattern, but it gives you zero explanation — no “unterminated group”, no “invalid escape”. You just get a red box. My workflow now: write and test the pattern at regex101.com with a few sample log strings, then paste it into DevTools. The PCRE flavor differences are minor enough that regex101 catches 95% of issues before you touch the browser.

# Patterns that look fine but cause the red box in Chrome:
-/[uncaught/        # Missing closing bracket
-/(firebase|/       # Unclosed group
-/d{3,/            # Incomplete quantifier

# What you actually want:
-/(firebase|stripe|intercom)/i
-/[HMR]|[WDS]/

Here’s a subtle one that causes real confusion during debugging sessions: filtering hides the log entries in the message list, but the Console tab icon still shows its red or yellow badge. That badge reflects the raw error/warning count that the browser tracked — not what’s visible after filters are applied. I’ve watched developers declare “no errors in production replay” while a red badge sits right there on the Console tab. The filter is a display lens, not a mute switch on the underlying event stream.

Browser extensions that wrap console methods before DevTools attaches are genuinely unfilterable through the UI. Extensions like React DevTools, Redux DevTools, or certain ad blockers sometimes monkey-patch console.log early in the page lifecycle — before the DevTools panel connects. Messages they emit bypass the filter layer because they’re injected at the content-script level, not the page’s JS context. You’ll see them regardless of what you put in the filter bar. The only escape is disabling the extension or running in a clean profile.

The filter matches against the rendered text content of a log, not the variable name or source file path. If you log a plain object and its stringified form happens to contain “intercom” — say, a config object with an intercomAppId field — that message gets caught by -/intercom/ even though you intended to keep it. This is especially sharp with objects that have nested third-party keys. Log a Redux state snapshot and suddenly half your state debugging disappears because some reducer key matched your noise filter. When that happens, switch from filtering on the vendor name to filtering on the exact prefix the vendor uses, like -/[Intercom]/ with the brackets escaped.

Quick Reference: Chrome vs Firefox Filter Syntax

The filter syntax differences between browsers are small but will absolutely break your workflow if you’re copy-pasting filters between environments. The one that gets me every time: Chrome uses - prefix for negation, Firefox uses !. Both work in regex mode, but the prefix before the pattern is different.

# Chrome — negate with hyphen
-/pattern/

# Chrome — hide noisy HMR and [vite] prefixed messages
-/[HMR]|[vite]/i

# Firefox — negate with exclamation mark
!/pattern/

# Firefox — same filter, Firefox syntax
!/[HMR]|[vite]/i

Here’s the full syntax comparison side by side:

Feature

Chrome

Firefox

Safari 17

Negation prefix

-

!

N/A

Case-insensitive flag (/i)

✅ Supported

✅ Supported

❌ Not applicable

OR within pattern (|)

✅ e.g. -/foo|bar/

✅ e.g. !/foo|bar/

❌ Not applicable

Regex support at all

✅

✅

❌ Plain text only

Filter persists across DevTools close/reopen

❌ Clears on close

✅ Persists per session

N/A

The Chrome persistence issue is a real daily annoyance. Every time you close DevTools and reopen them, your filter string is gone. My workaround is keeping a comment at the top of whatever project I’m actively debugging with the filter I want — just copy-paste it back in. Firefox at least remembers the filter for the duration of your browser session, so you only lose it on full browser restart. Neither browser saves it permanently, which would be the actually useful behavior.

Safari 17’s console filter bar looks identical to Chrome’s visually, which makes it extra confusing when you type -/redux/ and absolutely nothing happens — no error, no indication that regex isn’t supported, it just treats the whole string as a literal text search. If you’re debugging on Safari (common for iOS-specific issues), your only option is typing the plain text you want to show, not hide. There’s no negation at all. That means you can filter to see only matching messages, but you can’t suppress specific noise while keeping everything else — a meaningful limitation when you’re trying to isolate one specific error in a chatty console.

One practical tip for the /i flag: use it almost always. Console messages from third-party libraries are inconsistent about capitalization — you’ll see [Warning], [warning], and WARNING from different packages in the same project. A filter like -/deprecat/i catches all of them in one shot rather than needing to chain three separate patterns.

Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.

Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.

Would a 2000-2021 ML Paper Get Accepted Today? The Rising Bar in ML Research

A recurring question on r/MachineLearning asks whether the papers that defined modern deep learning — the ones written between 2000 and 2021 — would survive peer review if they were submitted fresh today. It is not a nostalgia exercise. The answer tells you how fast the floor has risen, and that floor is the same one your production AI tools are now measured against.

We went back through what acceptance at NeurIPS and ICML actually required at three points in time, and the gap is wider than the “papers are longer now” complaint suggests.

What changed between 2014 and now

The headline number is volume. NeurIPS received fewer than 2,000 submissions in 2014. By the early 2020s it was taking in well over 12,000, and recent years have pushed past 15,000. ICML followed the same curve. Acceptance rates, meanwhile, barely moved — they have stayed in a roughly 20–26% band the entire time.

Hold those two facts together. The same slice of papers gets in, but the pool it is drawn from is seven to nine times larger. A paper that landed in the top 25% in 2014 is not competing against the same field in 2026.

The 2014 NeurIPS program committee ran an experiment that still matters here. They routed about 10% of submissions through two independent review committees and compared verdicts. The committees disagreed on the majority of the papers that either one chose to accept — a result close enough to a coin flip that it has been cited ever since as evidence of review noise. NeurIPS repeated the experiment in 2021 and the disagreement rate had not improved.

Here is the uncomfortable implication. If review is that noisy, a rising bar does not cleanly reject weak work. It widens the band where solid work gets turned away because one reviewer wanted one more experiment. The bar did not get sharper. It got higher and stayed blurry.

The four things reviewers now expect

Read a review from 2015 and a review from 2025 side by side and four demands separate them.

Ablations. A 2014 paper could introduce an architecture and show it beat a baseline. Today a reviewer expects you to remove each component and quantify what it contributed. “Does the method work?” became “which part of the method works, and by how much?”

Baselines. One comparison now reads as cherry-picking. Reviewers expect current strong methods, tuned with the same budget you gave your own model. A favorable comparison against a weak baseline is a near-automatic weakness flag.

Variance. Single-run results draw immediate fire. The expectation is multiple random seeds with error bars or confidence intervals. The 2018 paper “Deep Reinforcement Learning That Matters” made this concrete by showing RL results that flipped sign across seeds, and the lesson generalized well past RL.

Reproducibility. NeurIPS added a reproducibility checklist in 2019 and a broader-impact statement in 2020, later folded into a mandatory paper checklist. Releasing code moved from optional to effectively expected.

None of these four demands is wrong on its own. Ablations, strong baselines, and seed variance each make a result more trustworthy. The problem is cumulative: a paper that satisfies all four needs experiments a 2014 lab could not have afforded on its compute budget. The bar is not just higher — it is more expensive to clear.

So would a classic paper survive? The original GAN paper from 2014 ran about eight pages with a handful of experiments, and against the literal 2026 checklist it would look thin — few baselines, limited ablation, qualitative samples instead of quantified variance. AlexNet in 2012 was similar in scope. Both reshaped the field. Both would draw a “needs more experiments” review today.

That is not really a knock on modern review. It is a sign that an idea-dense, experiment-light paper now has nowhere to land, because the format has standardized around exhaustive empirical validation.

What this means for the tools you ship

The research bar is not an academic curiosity for a developer. It is the spec that production AI tooling gets held to.

When a paper claims a model is better, the field now asks for ablations, matched baselines, and variance. The same questions are reaching vendor benchmarks. “Our model scores higher” invites “higher than which baseline, tuned how, averaged over how many runs?” The skepticism that hardened in peer review is leaking into procurement and into the evaluation suites teams run before adopting a tool.

If you build or evaluate AI features, the practical move is to apply the reviewer’s checklist before a reviewer — or a customer — applies it to you. Track which component of your system earns its keep. Keep an honest baseline. Run more than one seed. Treating your own work the way ICML treats a submission is now the cheap version of due diligence.

When you read a new model release, run the four-question filter: is there an ablation, are the baselines current and fairly tuned, are results averaged over seeds, and is there enough detail to reproduce the numbers? A release that fails three of the four is a marketing artifact, not a result.

Keeping that kind of structured record — papers, the claims they make, the baselines they were tested against, what actually reproduced — is its own small workflow problem.

The bar will keep rising. The honest read of the last decade is that it rose mostly by accumulating requirements, not by getting better at telling good ideas from bad ones. Build to the requirements — and keep your own judgment about which of them actually made the work better.

Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

Pragmatic Optimization in Mobile App Size: 3 Misconceptions

The Importance of Pragmatic Optimization in Mobile App Size

In the world of mobile app development, I often see that app size plays a critical role in a user’s decision to download an application. Especially in regions with limited data plans or slow internet speeds, even a difference of a few megabytes can directly impact download rates. This situation constantly pushes developers to reduce app size, sometimes leading them to adopt incorrect strategies.

As someone who has been involved with systems and software for nearly twenty years, I’ve also worked on various mobile projects. Whether it was my own Android spam app or a client’s Flutter-based mobile application, size optimization has always been a topic that comes up, but is often approached with one-dimensional solutions. In this post, I will share three fundamental misconceptions often made when optimizing mobile app size, and the lessons I’ve learned from these pitfalls.

Misconception 1: Focusing Only on the Code Stack for App Size

When it comes to reducing mobile app size, the first thing that usually comes to mind is the size of the compiled code. Developers immediately rush to tools like Proguard/R8 (for Android) or bitcode optimizations (for iOS). Yes, these tools can indeed provide significant reductions by eliminating unused code and optimizing existing code, but they are often only part of the picture. In my experience, the real reasons for an app’s bloat are usually hidden elsewhere.

Especially when developing with cross-platform frameworks like Flutter or in Android/iOS projects that use native C++ libraries, a large portion of the app’s total size can be made up of assets (images, videos, audio files, fonts) and native libraries (.so or .dylib files specific to an ABI). For example, in one of my mobile apps, when I forgot to manually check for native libraries compiled for multiple ABIs (ARMv7, ARM64, x86), the app’s size unnecessarily increased by approximately 25 MB. While the modern Android App Bundle (AAB) format largely solves this problem, such details can be overlooked in older APK distributions or manual configurations.

ℹ️ ABI Support and Size

Android applications often contain native libraries compiled for multiple Application Binary Interfaces (ABIs). Each ABI supports a different CPU architecture. Your app including its own set of native libraries for every target ABI can significantly increase app size. While App Bundle solves this on a per-device basis, using abiFilters is critical for manual APK distributions.

Another major factor is high-resolution or unoptimized visual assets. In an ERP project, for the operator screens I developed, the icons and background images used for user interface elements were initially in PNG format and at a resolution higher than necessary. This caused visuals to make up more than 30% of the app’s total size. With a simple WebP conversion and resolution optimization, we managed to reduce this size by more than half.

# Example command to analyze the content of an Android APK file
# This provides a general overview of file types and sizes within the APK.
# The 'assets/' and 'lib/' directories are often the biggest sources of bloat.
# apk-analyzer or Android Studio's APK Analyzer tool provides more detailed information.

# To extract an APK to a directory (just an example, actual tools may vary)
# unzip -l my_app.apk | awk '{print $NF, $1}' | sort -rh | head -n 10

# A structure similar to the output of Android Studio's APK Analyzer:
# Total Size: 65.2 MB
# ├── classes.dex: 8.5 MB (13.0%)
# ├── res/: 12.3 MB (18.9%)
# │   ├── drawable-xxhdpi/: 6.1 MB (9.4%)
# │   ├── mipmap-xxhdpi/: 3.2 MB (4.9%)
# │   └── ...
# ├── lib/: 24.7 MB (37.9%)
# │   ├── arm64-v8a/: 12.1 MB (18.6%)
# │   ├── armeabi-v7a/: 10.8 MB (16.6%)
# │   └── x86/: 1.8 MB (2.7%)
# ├── assets/: 15.1 MB (23.2%)
# │   ├── images/: 8.9 MB (13.7%)
# │   ├── fonts/: 4.2 MB (6.4%)
# │   └── ...
# └── META-INF/: 0.2 MB (0.3%)
# └── AndroidManifest.xml: 0.1 MB (0.2%)

As seen in the hypothetical analysis output above, the lib/ and assets/ directories make up more than 60% of the total size. This clearly demonstrates how insufficient focusing solely on code optimization can be. A true optimization effort requires a holistic approach that covers all these components.

Misconception 2: Manually Compressing Everything is Always Best

Acting with the “let’s compress everything” mentality to reduce app size is also a common misconception. Especially for visuals or other media files, every developer tends to find and use their own compression tool. However, these manual compression processes can lead to both a waste of time and, in some cases, may not provide the expected benefit, or even negatively impact performance.

Modern build systems and distribution platforms (e.g., Google Play Store’s AAB format) already offer advanced mechanisms to optimize your app’s distribution size. AAB delivers optimized resources based on the device’s language, screen density, and ABI, providing the user with only what they need. This reduces the developer’s need to manually create different asset sets or deal with complex compression algorithms. In my experience, when we switched to AAB, an automatic reduction of 20-30% was achieved compared to the standard APK size.

⚠️ Risks of Manual Compression

Aggressively compressing manually can degrade visual quality or increase decompression time at runtime, negatively impacting the app’s startup performance. Additionally, some compression algorithms may conflict with optimizations already applied by build systems and may not provide the expected benefit.

Regarding image compression, switching to modern formats like WebP instead of PNG or JPEG is generally a good step. WebP can offer the same visual quality with much smaller file sizes. However, a balance must be maintained here too. For example, converting very small icons to WebP might create millimeter differences in file size, but could cause additional CPU cycles for decompression. In a previous client project, while we were happy to “reduce the size” by converting every icon to WebP, we later noticed a micro-level increase in the app’s startup time. This was because the decompression of hundreds of small WebP files collectively created a significant overhead.

// In Android's build.gradle (app level) file, eliminating unnecessary language and density resources with resConfigs
// This can be useful if you're not using AAB or want additional optimization.
android {
    defaultConfig {
        // ...
        // Include only English and Turkish resources
        resConfigs "en", "tr"
        // Include only xhdpi and xxhdpi densities
        // The rest are automatically distributed by the Play Store or not used on lower-end devices.
        resConfigs "en", "tr", "xhdpi", "xxhdpi"
    }

    buildTypes {
        release {
            minifyEnabled true // Enable code shrinking
            shrinkResources true // Remove unused resources
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

The build.gradle configuration above provides an example of how we can automatically perform some resource optimizations without manual intervention. With resConfigs, we can specify which language and screen density resources should be included in the APK. Using such automatic optimizations can yield much more efficient and error-free results than manual efforts. The key is to understand the capabilities of the build system well and configure them correctly.

Misconception 3: Small App Size Equals High Performance Perception

This is perhaps one of the most insidious misconceptions. It’s often thought that a small app download size directly implies good runtime performance. However, while these two metrics are related, they are not always directly proportional, and sometimes optimizing one may require sacrificing the other. An app’s performance is determined not only by its file size but by many factors such as startup time, memory usage, CPU cycles, and the number and size of network requests.

In a mobile application we developed for a bank’s internal platform, we initially adopted a “lazy-load” strategy for all assets and data to keep the app’s download size as low as possible. The app size was indeed very small, but users encountered long loading times with every screen transition or initial launch. This was because every necessary asset or piece of data was fetched instantly over the network. This situation severely degraded the user experience, especially under poor network conditions.

💡 Performance-Oriented Size Optimization

When optimizing app size, it’s crucial to focus not only on the download size but also on runtime metrics such as app startup time (cold start), inter-screen transition performance, and memory usage. Sometimes a slightly larger app can offer a much smoother user experience by pre-loading necessary resources.

To solve this problem, we chose to package critical assets and data to be displayed on initial launch within the application or cache them. The app size increased slightly, but the startup time was reduced by 40%, and users experienced a much smoother interface. This experience showed me that an obsession with “small size” can sometimes lead to neglecting user experience. I had done a more detailed analysis on [related: mobile app performance optimization].

// adb command to inspect memory usage on Android
// This command shows detailed instantaneous memory usage of an application.
// The "Native Heap", "Dalvik Heap", and "ART Heap" sections are particularly important.
adb shell dumpsys meminfo com.example.myapp

# Excerpt from example output:
# Total PSS (RAM): 125 MB
#     Dalvik Heap:   30 MB (30 MB private dirty, 0 MB shared clean)
#     Native Heap:   45 MB (45 MB private dirty, 0 MB shared clean)
#     ART Heap:      20 MB (20 MB private dirty, 0 MB shared clean)
#     ...
# These outputs help you understand which components of your app consume how much memory.
# Large images, memory leaks, or inefficient data structures can bloat the native/dalvik heap.

The adb dumpsys meminfo output above is a critical tool for monitoring an app’s runtime memory usage. Sometimes an app with a small download size can consume too much memory at runtime, leading to device slowdowns or app crashes. Therefore, when making optimization decisions, it’s necessary to look at all resource consumption during use, not just the download size.

Pragmatic Solutions and Strategies

With the lessons learned from these misconceptions, I’ve developed a more balanced and pragmatic approach to mobile app size optimization. Here are some strategies I apply:

  1. Comprehensive Size Analysis: The first step should always be a comprehensive analysis. Tools like Android Studio’s APK Analyzer or Xcode’s Build Reports clearly show how much space each component of your app occupies. This analysis should include not only code but also assets, native libraries, and resources. Especially if you’re using AAB, it’s beneficial to review Play Store’s own size reports.
  2. Automate Asset Optimization: Avoid manual processes for optimizing visual and media assets. Prefer modern formats like WebP, AVIF, and integrate these conversions into your build process. For example, by adding tools like imagemin to your CI/CD pipeline, you can ensure that visuals are automatically optimized with every commit. I detailed the image optimization processes I used for a side product’s website in the [related: website image optimization] post.
  3. Dynamic Feature Modules: Features like Android’s Dynamic Feature Modules and iOS’s On-Demand Resources (ODR) offer the ability to download certain parts of the app only when needed. This significantly reduces the initial download size, allowing users to access the app faster. In a client project, we reduced the initial download size by 15% by making rarely used reporting modules dynamic.
  4. Native Library Management: Use abiFilters to eliminate unnecessary ABIs or let AAB handle this for you. If you’re developing your own native libraries, include only the functions truly needed and optimize compilation options. From what I’ve seen, native libraries compiled with an “everything included” mentality often grow to large sizes.
  5. Monitor Runtime Performance Metrics: While reducing app size, continuously monitor runtime performance metrics such as app startup time, memory usage, and CPU consumption. Firebase Performance Monitoring or similar tools will provide valuable data in this regard. Remember, the ultimate goal is to provide a fluid and enjoyable user experience, not just to reduce download size.

Lessons and Next Steps

Mobile app size optimization is not a one-time task but an ongoing process throughout the app’s lifecycle. In my twenty years of experience, I’ve always found myself doing things wrong, then going back and fixing them. The important thing is not to shy away from making these mistakes and to learn a lesson from each one. For example, last month in a Flutter project, I realized that a single APK was unnecessarily bloated because I forgot to use the flutter build apk --split-per-abi command. Although it’s a simple command, it can be overlooked due to a lack of automation.

In my future projects, I will continue to develop mobile applications, especially in areas such as AI-powered operations and production planning. In these projects, managing model sizes and the data sets required for RAG (Retrieval-Augmented Generation) will make app size even more complex. Therefore, I plan to more actively use approaches like dynamic feature modules to lazy-load AI models and data.

Developments in mobile platforms require us to continuously evolve our optimization approaches. New compression algorithms, new distribution formats, and increasing device processing power offer us more options. However, finding the right balance among these options still relies on the developer’s experience and analytical skills.

Conclusion: A Balanced Approach is Essential

When optimizing mobile app size, getting fixated solely on the size of compiled code or visual assets prevents us from seeing the whole picture. Furthermore, trying to manually compress everything or assuming that a small size will always provide the best performance can also mislead us. True optimization is possible with a balanced approach that covers all components of the app, utilizes the capabilities of build systems, and centers on user experience.

For me, it’s important to consider not only the impact of an optimization decision on a single metric but also its potential effects on all other metrics. App size can be a starting point, but the ultimate goal is always to deliver a fast, fluid, and user-friendly application. I will continue to pursue this balance.

Writing a Bootloader for My OS: Common Pitfalls and How I Fixed Them

Writing a Bootloader for My 32-bit x86 OS: Common Pitfalls and How I Fixed Them

I decided to write my own 32-bit x86 operating system from scratch. The first step was a bootloader – the small piece of code that loads the kernel. It’s supposed to be simple, but I ran into several frustrating issues. This post documents the real problems I faced and how I solved them, so you can avoid the same traps.

1. The BIOS signature

The boot sector must end with the magic bytes 0x55AA. The BIOS checks these two bytes before loading the sector. I forgot them, and nothing happened.

Fix: Add dw 0xAA55 at the end of your bootloader code, after filling the rest of the 512-byte sector with zeros.

times 510-($-$$) db 0
dw 0xAA55
  1. Losing the boot drive number

The BIOS passes the disk number in the dl register. I accidentally overwrote it before using it in the int 0x13 call. The disk read failed silently.

Fix: Save dl right at the start:


mov [boot_drive], dl

Then restore or use the saved value when reading sectors.

  1. A20 line – the hidden gatekeeper

To access memory above 1 MB and enter 32‑bit protected mode, the A20 line must be enabled. I wasted hours trying to figure out why my far jump crashed. The quickest way (though not the most universal) is to use port 0x92.


in al, 0x92
or al, 2
out 0x92, al

Many emulators enable A20 by default, but real hardware doesn’t – always include this step.

  1. Global Descriptor Table (GDT) mistakes

A flat memory model requires at least two descriptors: code and data. I messed up the segment limits and flags, causing general protection faults.

Correct minimal GDT for flat model (32‑bit):


gdt_start:
    dq 0                       ; null descriptor
    dw 0xFFFF, 0x0000, 0x9A00, 0x00CF   ; code segment 0x08
    dw 0xFFFF, 0x0000, 0x9200, 0x00CF   ; data segment 0x10
gdt_end:

Load it with lgdt [gdt_desc] before switching to protected mode.

  1. The protected mode jump

After setting the PE bit in cr0, you must perform a far jump to reload CS with the new code segment selector. I initially forgot the jmp and the CPU continued with the old real‑mode CS, causing a triple fault.


mov eax, cr0
or eax, 1
mov cr0, eax
jmp 0x08:protected_mode   ; 0x08 is our code segment selector
  1. Segment registers in protected mode

Inside the kernel (or later, in interrupt handlers), I naively loaded ds = 0x1000 and es = 0xB800, assuming they would work like in real mode. That’s wrong – in protected mode they must hold selectors (indices into GDT). This caused instant #GP.

Fix: Use the data segment selector (0x10) and access video memory directly by its linear address:


mov ax, 0x10    ; data selector
mov ds, ax
mov es, ax

; Write character via linear address 0xB8000
mov edi, [cursor_pos]
shl edi, 1
add edi, 0xB8000
mov ah, 0x07
mov [edi], ax

Result: a working bootloader + kernel

After fixing these issues, the bootloader loads the kernel, enters protected mode, and runs a simple command line. Everything is open source.

GitHub repository: github.com/Paullknya/AXonOS-kernel
Project discussions and updates: paullknya.github.io

If you’re also writing a hobby OS, I hope this saves you a few hours of debugging. Feel free to ask questions in the discussions – I’ll be happy to help.

Happy coding!

Anthropic June 15 Pricing: Where Should Your Claude Personal Assistant Live?

You can run a Claude personal assistant in two very different places, and for a long time the choice came down to whatever you set up first. Anthropic’s June 15 pricing update is a good reason to run the math instead.

A Claude assistant — the bot that triages your inbox, drafts your standup, or watches a deploy — can live as a Managed Agent on Anthropic’s infrastructure, or as a local, always-on Claude Code instance on a machine you control. Tools like Hermes Agent and OpenClaw sit on top of both; they orchestrate prompts, tools, and memory, but they do not answer the hosting question. Something still has to host the model calls and the file access. Here is how to decide what.

What the June 15 change actually reframes

Pricing updates are easy to skim as “numbers moved.” This one rewards a closer read, because it sharpens a distinction that was always there but easy to ignore: the difference between paying for work and paying for availability.

A Managed Agent is metered. It bills close to the work performed. An agent that fires twice a day costs roughly two runs’ worth of tokens a day, and effectively nothing while it sits idle between them.

A local always-on instance is the opposite. The machine runs whether the assistant is working or not. You carry that baseline — power, hardware, the server if it is not your laptop — no matter how many tasks actually land.

Metered work versus standing availability. Whichever direction Anthropic’s specific numbers move on June 15, that is the axis the decision turns on, and the date is a clean prompt to sort your assistants along it.

When Managed Agents win

Managed Agents win when the work is spiky, scheduled, or parallel.

Spiky work is event-driven: an assistant that reacts to a new GitHub issue, a Stripe event, or a calendar invite does nothing for hours and then needs to handle several things at once. On a single local instance those events queue behind each other. A Managed Agent fans them out.

Scheduled work is the 7 a.m. digest, the nightly repo summary, the Friday metrics roundup. It has to run when your laptop is closed. A local instance that must be awake to fire is the wrong tool — you would keep a machine on for 24 hours to do 15 minutes of work.

Parallel work is the research task you want in ten variations. Managed Agents let you start ten and collect the results; a local instance runs them one after another.

The cost structure favors this pattern bluntly. A scheduled agent that runs 60 times a month bills for 60 runs. Hosting the same job on an always-on machine means paying for roughly 720 hours of uptime to capture those same 60 runs — the standing cost dwarfs the work.

If your assistant’s job description is “do X every morning” or “react when Y happens,” it is a scheduled or event-driven workload, and it almost certainly belongs in a Managed Agent. The instinct to keep it local is usually leftover habit from when a Claude Code instance on your laptop was the only thing you had configured.

When a local Claude Code instance wins

A local Claude Code instance wins on the one thing a Managed Agent gives up: direct, unmediated access to your real environment.

Context. A local instance sees your actual repositories, your uncommitted changes, your shell history, the half-finished branch you were on this morning. A Managed Agent sees what you send it. For an assistant whose job is “help with whatever I am doing right now,” that gap is the entire product — there is no practical way to upload the state of your laptop to a cloud agent.

Files. If the assistant edits code, runs your test suite, and reads the build output, it needs a filesystem that is your filesystem. A Managed Agent can clone a repo, but it cannot see the local change you have not pushed — and unpushed changes are most of what matters while you are mid-task.

Permissions. A local instance runs with your credentials, on your network, behind your VPN. That is useful when the assistant legitimately needs an internal service, and it is a risk you hold directly instead of delegating to someone else’s infrastructure.

A local always-on instance runs with your credentials and your network access. That is convenient, and it is also the largest blast radius of any setup here. Scope what it can execute, keep secrets out of its reachable environment, and treat its inputs as untrusted — an assistant that reads your email is an assistant that can be instructed by your email.

The trade is real: you carry the standing cost of an always-on machine, and you own patching and uptime. For an assistant you genuinely talk to while you work, that cost buys something a Managed Agent cannot sell you.

A decision you can make in five minutes

Run your assistant’s job description through three questions.

Does it need to run when you are away from your machine? If yes, Managed Agent. A scheduled or event-driven job should never depend on your laptop being awake.

Does it need to see your local, unpushed, in-progress state? If yes, local Claude Code. No amount of context-pasting reconstructs a working tree.

Is the work spiky or parallel? If yes, Managed Agent. Standing availability is an expensive way to absorb burst load.

Most people find they have two assistants, not one. The morning digest and the webhook handler are Managed Agent work. The pair-programmer that lives inside your repo is local work. After June 15, splitting them is the cheaper arrangement and not only the cleaner one — you stop paying for hundreds of hours of uptime to host a job that runs for 20 minutes a day.

Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

I got my first PR merged in GSSoC 2026 and here’s exactly what happened

I fixed my first real bug in open source at GSSoC 2026

I’ve been participating in GSSoC 2026 (GirlScript Summer of Code) and today I got my first real code bug fix merged. Not a README edit. Not adding to a list. An actual bug fix in Python code.

Here’s exactly what happened.

The Bug

In the project prajwalsuryawanshi/agentapi, there was a function called to_tool_definition().

The problem — when a developer used the @tool decorator to give their tool a custom name, description and context, the function was completely ignoring all of that. It was just using the raw function name and raw docstring instead.

So if you wrote:

@tool(name="my_custom_tool", description="does something useful")
def some_function():
    """raw docstring"""
    pass

The tool definition would show some_function and raw docstring — completely ignoring what the developer wrote in the decorator. That’s a real bug.

What I Fixed

Just 3 lines changed. But those 3 lines fixed the entire problem.

  • to_tool_definition() now reads the custom tool name from the @tool decorator
  • It now uses the composed description and context instead of the raw docstring

Clean, minimal, focused fix.

The Labels on My PR

The maintainer added these labels before merging:

  • gssoc:approved
  • level:beginner
  • type:bug
  • tool calling

Even a CodeRabbit AI bot reviewed it and said “No actionable comments” which basically means the code was clean enough that even the bot had nothing to complain about 😄

What I Learned

Before this I thought open source contributions had to be huge. Like adding entire features or rewriting systems.

This was 3 lines. But it fixed a real problem that real developers using this library were hitting.

That’s what open source actually is — finding something broken and making it slightly less broken.

My GSSoC journey so far

This is my first week properly contributing to open source.
Before this I also got a PR merged in pcqpcq/open-source-android-apps (10k+ stars) where I added 3 missing apps to a curated list.

But this bug fix feels different. This was actual code. This was finding a real problem, understanding why it was broken, and fixing it properly.

More contributions coming. If you’re also doing GSSoC 2026 feel free to connect — always happy to help beginners get their first PR merged!

GSSoC2026 #OpenSource #Python #BugFix