<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Royce</title>
    <description>The latest articles on DEV Community by Royce (@royce_fabbd83cb268312e928).</description>
    <link>https://dev.to/royce_fabbd83cb268312e928</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3797280%2F8e63663e-9eae-47f7-92e0-4522701ef4b7.png</url>
      <title>DEV Community: Royce</title>
      <link>https://dev.to/royce_fabbd83cb268312e928</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/royce_fabbd83cb268312e928"/>
    <language>en</language>
    <item>
      <title>Authentik vs Keycloak vs Authelia SSO 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:11:23 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/authentik-vs-keycloak-vs-authelia-sso-2026-37g6</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/authentik-vs-keycloak-vs-authelia-sso-2026-37g6</guid>
      <description>&lt;h1&gt;
  
  
  Authentik vs Keycloak vs Authelia: Self-Hosted SSO and Auth 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Self-hosted SSO in 2026 has three serious options. &lt;strong&gt;Authelia&lt;/strong&gt; is a lightweight reverse proxy authentication layer — under 30MB RAM, configured in YAML, purpose-built for adding 2FA to homelab services. &lt;strong&gt;Authentik&lt;/strong&gt; is a full identity provider with a modern UI, flow-based customization, and OIDC/SAML support — the default recommendation for teams who want proper SSO without Keycloak's complexity. &lt;strong&gt;Keycloak&lt;/strong&gt; is the enterprise standard — Java-based, Red Hat-backed, supports every protocol including Kerberos and Active Directory federation, but requires 400MB–2GB RAM and significant configuration investment. Start with Authelia for homelab, move to Authentik for team use, consider Keycloak only if enterprise AD integration is non-negotiable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authelia&lt;/strong&gt;: ~22K stars, Apache-2.0, Go — lightweight proxy auth, ~20MB container, YAML config, 2FA, not a full IdP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentik&lt;/strong&gt;: ~15K stars, MIT, Python/Go — full IdP, OIDC/SAML/LDAP, visual flow builder, 2GB RAM minimum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keycloak&lt;/strong&gt;: ~25K stars, Apache-2.0, Java (Quarkus) — enterprise IdP, every protocol including Kerberos, 400MB–2GB+ RAM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Critical distinction&lt;/strong&gt;: Authelia is proxy authentication middleware, not an identity provider — it cannot issue OIDC tokens for apps that need proper OAuth flows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recommendation by use case&lt;/strong&gt;: Homelab → Authelia; SMB/Team → Authentik; Enterprise with AD → Keycloak&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authelia + Authentik&lt;/strong&gt;: Many setups combine both — Authelia for simple reverse proxy protection, Authentik as the OIDC backend&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Understanding the Difference: Proxy Auth vs Identity Provider
&lt;/h2&gt;

&lt;p&gt;Before comparing these three tools, a critical distinction: &lt;strong&gt;Authelia is not an identity provider&lt;/strong&gt;. This is the most common point of confusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy authentication (Authelia):&lt;/strong&gt; Sits in front of your services in your reverse proxy (Traefik, Nginx, Caddy). When you visit &lt;code&gt;service.yourdomain.com&lt;/code&gt;, Authelia checks if you're authenticated. If not, it shows a login page. It stores your credentials and issues a session cookie. It's excellent for protecting services that don't have built-in authentication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity provider (Authentik, Keycloak):&lt;/strong&gt; Issues OIDC tokens and SAML assertions that applications use to authenticate users via standard protocols. Apps like Gitea, Outline, Nextcloud, and Grafana have built-in SSO support that requires an OIDC/SAML provider. Only Authentik and Keycloak can fill this role.&lt;/p&gt;

&lt;p&gt;In practice, many homelabs run both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authelia handles basic HTTP authentication for simple apps (simple dashboards, internal tools)&lt;/li&gt;
&lt;li&gt;Authentik (or Keycloak) handles OIDC SSO for apps with proper OAuth support&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Authelia&lt;/th&gt;
&lt;th&gt;Authentik&lt;/th&gt;
&lt;th&gt;Keycloak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;~22K&lt;/td&gt;
&lt;td&gt;~15K&lt;/td&gt;
&lt;td&gt;~25K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Python (Django) + Go&lt;/td&gt;
&lt;td&gt;Java (Quarkus)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM (idle)&lt;/td&gt;
&lt;td&gt;~20–30MB&lt;/td&gt;
&lt;td&gt;~500MB–1GB&lt;/td&gt;
&lt;td&gt;~400MB–2GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM (recommended)&lt;/td&gt;
&lt;td&gt;256MB&lt;/td&gt;
&lt;td&gt;2GB&lt;/td&gt;
&lt;td&gt;4GB+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is a full IdP?&lt;/td&gt;
&lt;td&gt;No (proxy auth only)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OIDC/OAuth 2.0&lt;/td&gt;
&lt;td&gt;Limited (proxy only)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAML 2.0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (most complete)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LDAP&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (LDAP outpost)&lt;/td&gt;
&lt;td&gt;Yes (federation + outbound)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active Directory&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (best support)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kerberos&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social login&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA/2FA&lt;/td&gt;
&lt;td&gt;Yes (TOTP, WebAuthn, Duo)&lt;/td&gt;
&lt;td&gt;Yes (TOTP, WebAuthn, FIDO2)&lt;/td&gt;
&lt;td&gt;Yes (TOTP, WebAuthn, SMS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Passkeys/WebAuthn&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-registration&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User management UI&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good (complex)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flow customization&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (visual builder)&lt;/td&gt;
&lt;td&gt;Yes (complex)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branding/themes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenancy&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (realms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Federation&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (IdP chaining)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config format&lt;/td&gt;
&lt;td&gt;YAML files&lt;/td&gt;
&lt;td&gt;Web UI + API&lt;/td&gt;
&lt;td&gt;Web UI + CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitOps-friendly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup complexity&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reverse proxy integration&lt;/td&gt;
&lt;td&gt;Native (Traefik/Nginx/Caddy)&lt;/td&gt;
&lt;td&gt;Via outpost/proxy&lt;/td&gt;
&lt;td&gt;Via reverse proxy headers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Resource Usage in Detail
&lt;/h2&gt;

&lt;p&gt;Resource usage is often the deciding factor for homelab users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authelia
&lt;/h3&gt;

&lt;p&gt;Authelia's Go binary is remarkably lean. The Docker container is under 20MB in size. At runtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Idle RAM: 15–30MB&lt;/li&gt;
&lt;li&gt;Peak (during auth flow): ~50–100MB&lt;/li&gt;
&lt;li&gt;Storage: Minimal (SQLite by default, a few MB for session data)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can run Authelia on a Raspberry Pi alongside dozens of other services without noticing it. This is its killer advantage for resource-constrained homelabs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentik
&lt;/h3&gt;

&lt;p&gt;Authentik requires PostgreSQL and Redis alongside the main server container. Total stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentik server: ~300–500MB RAM&lt;/li&gt;
&lt;li&gt;Authentik worker: ~200–400MB RAM&lt;/li&gt;
&lt;li&gt;PostgreSQL: ~100–300MB RAM&lt;/li&gt;
&lt;li&gt;Redis: ~30–50MB RAM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total: ~700MB–1.2GB RAM&lt;/strong&gt; for a lightly-used instance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a team setup, 2GB RAM dedicated to the Authentik stack is comfortable. The official documentation recommends 2 vCPU and 2GB RAM minimum.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keycloak
&lt;/h3&gt;

&lt;p&gt;Keycloak runs on Java (Quarkus), which means JVM overhead. However, Quarkus has significantly reduced Keycloak's startup time and memory footprint compared to the older WildFly-based versions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Idle RAM: ~400MB–800MB (Quarkus, depends on configuration)&lt;/li&gt;
&lt;li&gt;Under load: 1–4GB+ depending on concurrent sessions and realm complexity&lt;/li&gt;
&lt;li&gt;Database: External PostgreSQL required (add 100–300MB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total: ~600MB–2.5GB RAM&lt;/strong&gt; for a typical installation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For enterprise deployments, plan for 4–8GB RAM per Keycloak node, with clustering for high availability.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup and Configuration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Authelia
&lt;/h3&gt;

&lt;p&gt;Authelia is configured entirely through YAML files. This is one of its strongest features for GitOps and infrastructure-as-code workflows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# configuration.yml&lt;/span&gt;
&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9091&lt;/span&gt;

&lt;span class="na"&gt;log&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;info&lt;/span&gt;

&lt;span class="na"&gt;jwt_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;a-very-long-secret-key&lt;/span&gt;

&lt;span class="na"&gt;authentication_backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/config/users_database.yml&lt;/span&gt;

&lt;span class="na"&gt;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.yourdomain.com"&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;two_factor&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public.yourdomain.com"&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bypass&lt;/span&gt;

&lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authelia_session&lt;/span&gt;
  &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;another-long-secret&lt;/span&gt;
  &lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;inactivity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
  &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yourdomain.com&lt;/span&gt;

&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/config/db.sqlite3&lt;/span&gt;

&lt;span class="na"&gt;notifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;smtp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;you@gmail.com&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-app-password&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;smtp.gmail.com&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;587&lt;/span&gt;
    &lt;span class="na"&gt;sender&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth@yourdomain.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traefik integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In your Traefik-managed service's Docker labels:&lt;/span&gt;
&lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.myservice.middlewares=authelia@docker"&lt;/span&gt;

&lt;span class="c1"&gt;# Authelia middleware definition:&lt;/span&gt;
&lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/verify?rd=https://auth.yourdomain.com"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.authelia.forwardauth.trustForwardHeader=true"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Authentik Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.4"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/library/postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_DB}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_USER}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;database:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PG_PASS}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/library/redis:alpine&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--save 60 1 --loglevel warning&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis-cli&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;grep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PONG"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis:/data&lt;/span&gt;

  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/goauthentik/server:2024.12&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_REDIS__HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PG_PASS}&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${AUTHENTIK_SECRET_KEY}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./media:/media&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./custom-templates:/templates&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0:9000:9000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0:9443:9443"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;

  &lt;span class="na"&gt;worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/goauthentik/server:2024.12&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_REDIS__HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authentik&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_POSTGRESQL__PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PG_PASS}&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTIK_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${AUTHENTIK_SECRET_KEY}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./media:/media&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./certs:/certs&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./custom-templates:/templates&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Keycloak Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;keycloak&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/keycloak/keycloak:latest&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;start-dev&lt;/span&gt;  &lt;span class="c1"&gt;# Use 'start' for production&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jdbc:postgresql://postgres/keycloak&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
      &lt;span class="na"&gt;KC_DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_DB_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;KEYCLOAK_ADMIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
      &lt;span class="na"&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_ADMIN_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;KC_HOSTNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth.yourdomain.com&lt;/span&gt;
      &lt;span class="na"&gt;KC_PROXY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;edge&lt;/span&gt;  &lt;span class="c1"&gt;# Behind reverse proxy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${KC_DB_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pg_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pg_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: For production, use &lt;code&gt;start&lt;/code&gt; instead of &lt;code&gt;start-dev&lt;/code&gt; and configure &lt;code&gt;KC_HOSTNAME_STRICT=false&lt;/code&gt;, TLS certificates, and a proper database with backups.&lt;/p&gt;




&lt;h2&gt;
  
  
  Use Case Matrix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Homelab (1–5 users, personal services)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Recommended: Authelia&lt;/strong&gt; with optional Authentik for apps that need proper OIDC.&lt;/p&gt;

&lt;p&gt;Most homelab services (Portainer, Grafana, dashboards) don't have built-in auth. Authelia protects them with minimal resources. Add Authentik only for apps that have native OIDC support (Gitea, Outline, Nextcloud) and benefit from real SSO.&lt;/p&gt;

&lt;h3&gt;
  
  
  Small Team (5–50 users, business applications)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Recommended: Authentik&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Authentik handles the full SSO flow for modern business apps, has a user-friendly admin UI, and supports SAML for legacy enterprise apps. The flow-based customization lets you add registration approval, MFA enrollment, and custom login pages without writing code. Resource usage is manageable on a 2–4GB RAM VPS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mid-size Business (50–500 users, mixed legacy/modern apps)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Recommended: Authentik or Keycloak&lt;/strong&gt; depending on Active Directory requirements.&lt;/p&gt;

&lt;p&gt;If your organization has Windows Active Directory and needs to federate users from AD into your self-hosted apps, Keycloak's AD integration is more mature. If you're building green-field with modern protocols, Authentik handles this tier well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enterprise (500+ users, Kerberos, AD federation, complex compliance)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Recommended: Keycloak&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keycloak is the only option in this space with full Kerberos support, deep Active Directory federation, and the battle-tested scalability for high-concurrency enterprise auth. Red Hat's backing means long-term security patching. The configuration complexity is real, but enterprise IT teams are accustomed to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common Combinations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Authelia + Authentik:&lt;/strong&gt; Run Authelia for simple proxy auth on services without OIDC support, and point Authelia to Authentik as its LDAP/OIDC backend for user management. This gives you single user management with both proxy auth and full IdP capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentik + Authelia:&lt;/strong&gt; The reverse — use Authentik as the primary IdP, and deploy Authelia only for protecting legacy services that can't be updated with OIDC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keycloak + Vault:&lt;/strong&gt; For enterprise secrets management, Keycloak integrates with HashiCorp Vault (or Infisical) for federated identity in CI/CD pipelines.&lt;/p&gt;




&lt;p&gt;For hands-on setup guides, see our &lt;a href="https://dev.to/blog/how-to-self-host-authentik-identity-provider-sso-2026"&gt;how to self-host Authentik guide&lt;/a&gt;, our &lt;a href="https://dev.to/blog/how-to-self-host-authelia-authentication-middleware-2026"&gt;Authelia authentication middleware guide&lt;/a&gt;, and our &lt;a href="https://dev.to/blog/self-hosting-guide-keycloak-2026"&gt;Keycloak self-hosting guide&lt;/a&gt;. If you're building a full self-hosted stack and need to understand where SSO fits, the &lt;a href="https://dev.to/blog/best-open-source-authentication-solutions-2026"&gt;best open source authentication solutions overview&lt;/a&gt; covers the broader landscape.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting Common Issues
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Authelia: "missing redirects" on first login&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most common Authelia configuration error: the &lt;code&gt;session.domain&lt;/code&gt; in &lt;code&gt;configuration.yml&lt;/code&gt; must match the top-level domain of all your services. If your services are on &lt;code&gt;*.home.yourdomain.com&lt;/code&gt;, set &lt;code&gt;session.domain: home.yourdomain.com&lt;/code&gt;. A mismatch causes session cookies to not be shared across subdomains, resulting in infinite login loops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentik: Worker not starting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the Authentik worker container keeps restarting, check for PostgreSQL connection errors (&lt;code&gt;docker logs authentik-worker-1&lt;/code&gt;). The most common cause is a missing &lt;code&gt;AUTHENTIK_SECRET_KEY&lt;/code&gt; environment variable or a mismatch between the server and worker environment files. Generate a secret key with &lt;code&gt;openssl rand -base64 36&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keycloak: "Invalid redirect URI" on login&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Keycloak's admin console, every OIDC client must have its redirect URIs explicitly allowlisted. Navigate to Clients → [your client] → Settings → Valid redirect URIs and add &lt;code&gt;https://yourapp.yourdomain.com/*&lt;/code&gt;. The wildcard at the end is intentional. A missing or incorrectly scoped redirect URI is the most common Keycloak integration error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All three: Clock skew&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OIDC tokens have short expiry windows (typically 5 minutes). If your server's clock is more than a few minutes off, token validation will fail with cryptic errors. Run &lt;code&gt;timedatectl&lt;/code&gt; to check clock sync status and enable &lt;code&gt;systemd-timesyncd&lt;/code&gt; or &lt;code&gt;ntpd&lt;/code&gt; if needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backup and Disaster Recovery
&lt;/h2&gt;

&lt;p&gt;For SSO infrastructure, backup and recovery planning is critical — if your auth system goes down, users can't access any protected services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authelia&lt;/strong&gt; backup is simple: back up &lt;code&gt;configuration.yml&lt;/code&gt;, the users database file, and the SQLite file (if using local storage). Everything else is stateless. Recovery is fast — restore those files and restart the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentik&lt;/strong&gt; requires backing up the PostgreSQL database and the &lt;code&gt;/media&lt;/code&gt; volume (certificates, custom themes). Use &lt;code&gt;pg_dump&lt;/code&gt; for the database on a schedule. Authentik can export flows, applications, and providers as blueprints — storing these in git gives you configuration-as-code for rapid recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keycloak&lt;/strong&gt; state lives in PostgreSQL. Back up the database with &lt;code&gt;pg_dump&lt;/code&gt; and test restores regularly. Keycloak's realm export feature (&lt;code&gt;docker exec keycloak /opt/keycloak/bin/kc.sh export --dir /tmp/export&lt;/code&gt;) creates a JSON export of all realm configuration including clients, users (without passwords), and flows. This is invaluable for disaster recovery.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migrating from Auth0 or Clerk to Self-Hosted
&lt;/h2&gt;

&lt;p&gt;Many teams are moving from Auth0 (Okta) or Clerk to self-hosted identity solutions to control costs and data. Both Authentik and Keycloak can replace these services for most use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From Auth0&lt;/strong&gt;: Export users via Auth0's Management API (passwords are hashed and can be imported). In Authentik, configure OIDC applications with the same client IDs and secrets as your Auth0 apps — some apps can be migrated with a configuration change, no code changes required. In Keycloak, the migration tooling is similar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From Clerk&lt;/strong&gt;: Clerk's OIDC compatibility is limited; it's primarily an SDK-based product. Migration to Authentik or Keycloak typically requires updating application code to use standard OIDC libraries (e.g., &lt;code&gt;next-auth&lt;/code&gt; with an OIDC provider instead of Clerk's proprietary SDK).&lt;/p&gt;

&lt;p&gt;For detailed migration steps, see our &lt;a href="https://dev.to/blog/how-to-migrate-from-auth0-to-keycloak-2026"&gt;guide to migrating from Auth0 to Keycloak&lt;/a&gt; and our &lt;a href="https://dev.to/blog/best-open-source-alternatives-to-auth0-2026"&gt;best open source Auth0 alternatives overview&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sources consulted: 8&lt;/li&gt;
&lt;li&gt;GitHub star data from GitHub.com, March 2026&lt;/li&gt;
&lt;li&gt;RAM benchmarks from official documentation and community measurements&lt;/li&gt;
&lt;li&gt;Protocol support from official documentation for each project&lt;/li&gt;
&lt;li&gt;Date: March 2026&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>authentik</category>
      <category>keycloak</category>
      <category>authelia</category>
      <category>sso</category>
    </item>
    <item>
      <title>Hoarder vs Wallabag vs Linkwarden 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:10:41 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/hoarder-vs-wallabag-vs-linkwarden-2026-47f9</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/hoarder-vs-wallabag-vs-linkwarden-2026-47f9</guid>
      <description>&lt;h1&gt;
  
  
  Hoarder vs Wallabag vs Linkwarden: Self-Hosted Bookmark Managers 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;The three strongest self-hosted bookmark managers in 2026 each solve a different problem. &lt;strong&gt;Hoarder&lt;/strong&gt; (now rebranded as Karakeep) is the AI-first option: paste a URL and it auto-tags it using a local LLM, takes a screenshot, and archives the full page. &lt;strong&gt;Wallabag&lt;/strong&gt; is the mature read-later tool — open since 2013, stable, focused on distraction-free reading rather than link organization. &lt;strong&gt;Linkwarden&lt;/strong&gt; is the collaborative option with the most thorough archival format support (screenshot, PDF, HTML, Wayback Machine) and growing AI features. All three are free, self-hosted, and actively maintained.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hoarder/Karakeep&lt;/strong&gt;: ~10K stars, AGPL-3.0, Next.js — AI-powered auto-tagging via Ollama, full-page archiving, Pocket replacement with AI superpowers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wallabag&lt;/strong&gt;: ~12K stars, MIT, PHP — read-later focused, open since 2013, distraction-free reader mode, Pocket/Instapaper replacement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linkwarden&lt;/strong&gt;: ~16K stars, AGPL-3.0, Next.js — collaborative collections, screenshot+PDF+HTML archival, optional AI tagging via Ollama&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pocket is dead&lt;/strong&gt;: Mozilla shut down Pocket in July 2025, making 2026 the first full year where self-hosted alternatives are the only option&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI tagging&lt;/strong&gt;: Both Hoarder and Linkwarden integrate with Ollama for local LLM-based tag generation — no data sent to the cloud&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import/export&lt;/strong&gt;: All three support Netscape HTML bookmark format import (the universal bookmark export format from every browser)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Self-Host Your Bookmarks?
&lt;/h2&gt;

&lt;p&gt;Mozilla shut down Pocket in July 2025, forcing millions of users to find alternatives. The commercial options (Raindrop.io, Readwise Reader) are solid but cost $2.99–$7.99/month and process your bookmarks on their servers. If you're already running a home server or VPS, self-hosting a bookmark manager is a natural extension.&lt;/p&gt;

&lt;p&gt;Beyond cost, the privacy argument is compelling: your reading list reveals a lot about your interests, research, and professional focus. Self-hosting keeps that data on your hardware.&lt;/p&gt;

&lt;p&gt;The three tools here cover the main use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-later / distraction-free reading&lt;/strong&gt;: Wallabag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-organized bookmark collection&lt;/strong&gt;: Hoarder&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collaborative link collections with thorough archival&lt;/strong&gt;: Linkwarden&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Feature Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Hoarder&lt;/th&gt;
&lt;th&gt;Wallabag&lt;/th&gt;
&lt;th&gt;Linkwarden&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;~10K&lt;/td&gt;
&lt;td&gt;~12K&lt;/td&gt;
&lt;td&gt;~16K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stack&lt;/td&gt;
&lt;td&gt;Next.js (TypeScript)&lt;/td&gt;
&lt;td&gt;PHP (Symfony)&lt;/td&gt;
&lt;td&gt;Next.js (TypeScript)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI auto-tagging&lt;/td&gt;
&lt;td&gt;Yes (Ollama)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (Ollama, optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI summarization&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-page archival&lt;/td&gt;
&lt;td&gt;Yes (screenshot + text)&lt;/td&gt;
&lt;td&gt;Yes (article text only)&lt;/td&gt;
&lt;td&gt;Yes (screenshot + PDF + HTML)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wayback Machine&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readability extract&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (primary feature)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF archival&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser extension&lt;/td&gt;
&lt;td&gt;Chrome + Firefox&lt;/td&gt;
&lt;td&gt;Chrome + Firefox&lt;/td&gt;
&lt;td&gt;Chrome + Firefox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile app&lt;/td&gt;
&lt;td&gt;iOS + Android&lt;/td&gt;
&lt;td&gt;iOS + Android (unofficial clients)&lt;/td&gt;
&lt;td&gt;PWA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collaborative collections&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public link sharing&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS import&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pocket import&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wallabag import&lt;/td&gt;
&lt;td&gt;Via browser export&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Via browser export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tags&lt;/td&gt;
&lt;td&gt;Yes (AI + manual)&lt;/td&gt;
&lt;td&gt;Yes (manual)&lt;/td&gt;
&lt;td&gt;Yes (AI + manual)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-text search&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource usage&lt;/td&gt;
&lt;td&gt;~500MB RAM&lt;/td&gt;
&lt;td&gt;~256MB RAM&lt;/td&gt;
&lt;td&gt;~512MB RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;MySQL/PostgreSQL/SQLite&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Hoarder (Karakeep) Deep Dive
&lt;/h2&gt;

&lt;p&gt;Hoarder rebranded to Karakeep in late 2025 but the project is the same. The core value proposition: you save a URL and the work is done. Hoarder fetches the full page, takes a screenshot, extracts readable text, and sends that content to a local LLM (via Ollama) to generate 3–5 relevant tags automatically.&lt;/p&gt;

&lt;p&gt;The result is a bookmark library that stays organized without manual effort. Over time, your tags converge into a useful taxonomy of your interests — "devops", "python", "architecture", "security" — without you manually tagging each link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI models that work well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llama3.2:3b&lt;/code&gt; — fast, good tag quality, runs on 8GB RAM&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mistral:7b&lt;/code&gt; — better quality, needs 16GB RAM&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;phi3:mini-4k&lt;/code&gt; — smallest footprint, adequate quality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Noteworthy features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Notes and images saved alongside bookmarks (not just URLs)&lt;/li&gt;
&lt;li&gt;List view and card view with screenshots&lt;/li&gt;
&lt;li&gt;iOS and Android apps with share sheet support&lt;/li&gt;
&lt;li&gt;Full-text search across archived content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No collaborative features — single-user or per-account only&lt;/li&gt;
&lt;li&gt;No PDF archival (screenshot + text only)&lt;/li&gt;
&lt;li&gt;Less mature than Wallabag (launched 2024 vs Wallabag's 2013)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Hoarder Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/karakeep-app/karakeep:release&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;hoarder_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;3000:3000&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;HOARDER_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
      &lt;span class="na"&gt;NEXTAUTH_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-secret-here&lt;/span&gt;
      &lt;span class="na"&gt;NEXTAUTH_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3000&lt;/span&gt;
      &lt;span class="na"&gt;DATA_DIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data&lt;/span&gt;
      &lt;span class="na"&gt;MEILI_ADDR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://meilisearch:7700&lt;/span&gt;
      &lt;span class="na"&gt;MEILI_MASTER_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-master-key&lt;/span&gt;
      &lt;span class="c1"&gt;# Optional: Ollama for AI tagging&lt;/span&gt;
      &lt;span class="na"&gt;OLLAMA_BASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://ollama:11434&lt;/span&gt;
      &lt;span class="na"&gt;INFERENCE_TEXT_MODEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llama3.2:3b&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;meilisearch&lt;/span&gt;

  &lt;span class="na"&gt;meilisearch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;getmeili/meilisearch:v1.11&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MEILI_MASTER_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-master-key&lt;/span&gt;
      &lt;span class="na"&gt;MEILI_NO_ANALYTICS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;meilisearch_data:/meili_data&lt;/span&gt;

  &lt;span class="c1"&gt;# Optional: run Ollama in Docker&lt;/span&gt;
  &lt;span class="na"&gt;ollama&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama/ollama:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ollama_data:/root/.ollama&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hoarder_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;meilisearch_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ollama_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Wallabag Deep Dive
&lt;/h2&gt;

&lt;p&gt;Wallabag is the oldest of the three (2013) and the most focused: it's a read-later app, not a bookmark manager. The distinction matters. Wallabag fetches the article content, strips ads and navigation, and presents it in a clean reading view — similar to Pocket or Instapaper. It's not designed for saving URLs you want to reference later; it's for articles you want to read later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unique strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RSS feed import — save articles from feeds automatically&lt;/li&gt;
&lt;li&gt;E-reader export (EPUB, Mobi) for reading on Kindle&lt;/li&gt;
&lt;li&gt;Official mobile apps for iOS and Android (not just PWA)&lt;/li&gt;
&lt;li&gt;Annotate articles with highlights and notes&lt;/li&gt;
&lt;li&gt;Well-maintained PHP codebase with strong security track record&lt;/li&gt;
&lt;li&gt;MIT license (vs AGPL for Hoarder and Linkwarden)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No AI features (no auto-tagging, no summarization)&lt;/li&gt;
&lt;li&gt;No screenshot or PDF archival — saves article text only&lt;/li&gt;
&lt;li&gt;No collaborative features&lt;/li&gt;
&lt;li&gt;PHP stack may feel dated compared to Next.js alternatives&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Wallabag Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wallabag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wallabag/wallabag:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_ROOT_PASSWORD=wallaroot&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__DATABASE_DRIVER=pdo_mysql&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__DATABASE_HOST=db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__DATABASE_PORT=3306&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__DATABASE_NAME=wallabag&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__DATABASE_USER=wallabag&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__DATABASE_PASSWORD=wallapass&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__SECRET=change-me-to-a-long-random-string&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__FOSUSER_REGISTRATION=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYMFONY__ENV__DOMAIN_NAME=https://read.yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wallabag_images:/var/www/wallabag/web/assets/images&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_ROOT_PASSWORD=wallaroot&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_DATABASE=wallabag&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_USER=wallabag&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_PASSWORD=wallapass&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wallabag_db:/var/lib/mysql&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wallabag_images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wallabag_db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Linkwarden Deep Dive
&lt;/h2&gt;

&lt;p&gt;Linkwarden sits between Hoarder and Wallabag in philosophy. It's a bookmark manager (not a read-later tool) with the most thorough archival support of the three. For every saved URL, Linkwarden automatically stores multiple formats: screenshot, PDF, single-file HTML, and can optionally ping the Wayback Machine. Even if the original site goes down, you have four ways to access the content.&lt;/p&gt;

&lt;p&gt;The collaborative angle sets it apart: you can create shared collections with multiple users, each with their own permissions. This makes it usable for team link curation — a shared research collection, a team "read later" list, or a collaborative bookmarking tool for a small team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unique strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most thorough archival: screenshot + PDF + HTML + Wayback Machine&lt;/li&gt;
&lt;li&gt;Collaborative collections with user permissions&lt;/li&gt;
&lt;li&gt;16K stars — most popular of the three&lt;/li&gt;
&lt;li&gt;Clean UI with tag and collection organization&lt;/li&gt;
&lt;li&gt;Reader view for distraction-free reading&lt;/li&gt;
&lt;li&gt;Annotation support&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No native mobile apps (PWA only)&lt;/li&gt;
&lt;li&gt;AI features more recent and less polished than Hoarder&lt;/li&gt;
&lt;li&gt;No RSS import&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Linkwarden Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;linkwarden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/linkwarden/linkwarden:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;3000:3000&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;linkwarden_data:/data/data&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linkwarden&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pg_data:/var/lib/postgresql/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;linkwarden_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pg_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NEXTAUTH_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-long-secret
&lt;span class="nv"&gt;NEXTAUTH_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://links.yourdomain.com
&lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yourpassword
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgresql://postgres:yourpassword@postgres:5432/linkwarden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Importing from Pocket
&lt;/h2&gt;

&lt;p&gt;All three tools support Pocket import. The process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Export from Pocket: Visit &lt;a href="https://getpocket.com/export" rel="noopener noreferrer"&gt;getpocket.com/export&lt;/a&gt; and download your HTML export file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hoarder&lt;/strong&gt;: Settings → Import → Pocket HTML&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wallabag&lt;/strong&gt;: Import → Pocket → Upload HTML file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linkwarden&lt;/strong&gt;: Settings → Import → Netscape HTML Bookmarks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note: After Pocket's shutdown in July 2025, the export URL may no longer be accessible. If you have an old Pocket export, all three tools accept it. If you're migrating from another bookmark tool, most export to Netscape HTML format.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Hoarder if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want zero-effort organization — AI tags everything automatically&lt;/li&gt;
&lt;li&gt;You're replacing Pocket and want similar UX but with local AI&lt;/li&gt;
&lt;li&gt;You save a mix of links, notes, and images (not just URLs)&lt;/li&gt;
&lt;li&gt;You run Ollama already or want an excuse to try local LLMs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose Wallabag if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your primary use case is reading long-form articles later, not organizing links&lt;/li&gt;
&lt;li&gt;You want e-reader (Kindle/Kobo) export&lt;/li&gt;
&lt;li&gt;You need RSS feed integration&lt;/li&gt;
&lt;li&gt;MIT license is important&lt;/li&gt;
&lt;li&gt;You prefer a mature, stable, battle-tested codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose Linkwarden if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Archival completeness matters — you want screenshots, PDFs, AND HTML copies&lt;/li&gt;
&lt;li&gt;You're doing collaborative bookmarking with a team&lt;/li&gt;
&lt;li&gt;16K stars and the largest community gives you confidence&lt;/li&gt;
&lt;li&gt;You want optional AI features without making them mandatory&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;For more on self-hosted alternatives to commercial services, see our &lt;a href="https://dev.to/blog/how-to-self-host-hoarder-ai-bookmark-manager-2026"&gt;guide to self-hosting Hoarder&lt;/a&gt;, our &lt;a href="https://dev.to/blog/how-to-self-host-wallabag-read-later-pocket-alternative-2026"&gt;Wallabag self-hosting guide&lt;/a&gt;, and the &lt;a href="https://dev.to/blog/best-open-source-link-management-tools-2026"&gt;best open source link management tools&lt;/a&gt;. For the full privacy-focused self-hosted stack, see our &lt;a href="https://dev.to/blog/homelab-software-stack-guide-2026"&gt;homelab software stack guide&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Browser Extension Comparison
&lt;/h2&gt;

&lt;p&gt;All three tools provide browser extensions for one-click saving, but the experience differs meaningfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hoarder's extensions&lt;/strong&gt; (Chrome and Firefox) add a toolbar button that saves the current page immediately. A small popup confirms the save and shows the AI-generated tags once processing completes (usually 10–30 seconds). You can add manual tags or notes before saving. The extension also supports right-click saving of links from any page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wallabag's extensions&lt;/strong&gt; trigger a save and redirect you away — there's no confirmation popup by default. The experience is functional but dated compared to the newer tools. A companion iOS share extension exists for saving from Safari.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linkwarden's extensions&lt;/strong&gt; show a save dialog with collection selection and tag input. You can choose which collection to save to immediately, and the extension shows a progress indicator while archival runs (screenshot + PDF processing). The ability to select the target collection at save time is a meaningful UX advantage for users with complex collection structures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Self-Hosting Costs and Resources
&lt;/h2&gt;

&lt;p&gt;Running any of these tools on a VPS costs $4–$12/month depending on provider and configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hoarder&lt;/strong&gt; needs at minimum: 2 CPU cores, 1GB RAM for the app (excluding Ollama). Add 8–16GB RAM and 4+ CPU cores if you run Ollama on the same machine. The full stack with Ollama running &lt;code&gt;llama3.2:3b&lt;/code&gt; needs ~10GB RAM. Alternatively, point Hoarder at an Ollama instance on a different machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wallabag&lt;/strong&gt; is the lightest: 1 vCPU and 512MB RAM handle a single-user instance comfortably. A Raspberry Pi 4 runs it with room to spare. The only scalability constraint is MySQL/MariaDB performance on very large archives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linkwarden&lt;/strong&gt; needs 1–2 vCPU and 1–2GB RAM for the Next.js app and PostgreSQL. The archival processes (screenshot, PDF) can be CPU-intensive when saving many links simultaneously. A $6–8/month VPS handles typical individual or small team use.&lt;/p&gt;

&lt;p&gt;For storage, all three archive page content. Linkwarden's multi-format archival (screenshot + PDF + HTML) uses the most storage — budget 500KB–2MB per saved link. At 10,000 saved links, that's 5–20GB of archived content.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security and Privacy Notes
&lt;/h2&gt;

&lt;p&gt;The primary privacy benefit of self-hosting bookmark managers is that your reading list and research habits stay on your server. But there are secondary privacy considerations.&lt;/p&gt;

&lt;p&gt;When you save a link, all three tools fetch the URL from your server — meaning your server's IP address makes the HTTP request to the target website, not your personal device. This can be an advantage (websites log your server IP, not your home IP) or a disadvantage (your VPS provider can see outbound requests) depending on your threat model.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;Hoarder with Ollama&lt;/strong&gt;, page content is sent to the local LLM running on your server. No data leaves your infrastructure. If you're not running Ollama, AI features are disabled and no third-party AI services are contacted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wallabag's&lt;/strong&gt; article extraction is server-side. The PHP Mercury Parser-compatible extractor fetches and parses article content on your server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linkwarden's&lt;/strong&gt; Wayback Machine integration (optional) sends URLs to archive.org. Disable this if you don't want link data shared externally.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sources consulted: 7&lt;/li&gt;
&lt;li&gt;GitHub star data from GitHub.com, March 2026&lt;/li&gt;
&lt;li&gt;Docker Compose configs from official documentation&lt;/li&gt;
&lt;li&gt;Resource usage from community benchmarks and self-reported hardware requirements&lt;/li&gt;
&lt;li&gt;Date: March 2026&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hoarder</category>
      <category>wallabag</category>
      <category>linkwarden</category>
      <category>bookmarks</category>
    </item>
    <item>
      <title>Docmost vs Outline vs BookStack Wiki 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:09:56 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/docmost-vs-outline-vs-bookstack-wiki-2026-2el</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/docmost-vs-outline-vs-bookstack-wiki-2026-2el</guid>
      <description>&lt;h1&gt;
  
  
  Docmost vs Outline vs BookStack: Self-Hosted Wiki and Docs 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;For self-hosted wikis in 2026, the three strongest open source contenders are Docmost (modern block editor, real-time collab, AGPL), Outline (Notion-like polish, requires S3 + external auth), and BookStack (PHP, hierarchical structure, simplest setup). &lt;strong&gt;BookStack&lt;/strong&gt; wins for ease of deployment and anyone who doesn't have an identity provider set up. &lt;strong&gt;Outline&lt;/strong&gt; wins for teams who want the best editing experience and already run Authentik or Keycloak. &lt;strong&gt;Docmost&lt;/strong&gt; is the rising contender — newer but rapidly improving, with better Draw.io integration than either competitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BookStack&lt;/strong&gt;: ~16K stars, MIT, PHP/Laravel — easiest self-host, built-in email/password auth, hierarchical Books/Chapters/Pages structure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outline&lt;/strong&gt;: ~30K stars, BSL (source-available), TypeScript — best editing experience, requires external auth provider and S3 storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docmost&lt;/strong&gt;: ~11K stars, AGPL-3.0, TypeScript — newer, real-time collab, native Draw.io, no S3 dependency for basic setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth gotcha&lt;/strong&gt;: Outline requires an external OIDC/OAuth provider (no built-in login) — plan for Authentik/Keycloak before deploying&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License&lt;/strong&gt;: Outline uses Business Source License (not OSI-approved open source) — important if you have legal requirements for OSS-only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search quality&lt;/strong&gt;: Outline has excellent full-text search; BookStack is good; Docmost is catching up&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why These Three?
&lt;/h2&gt;

&lt;p&gt;The self-hosted wiki space is crowded — Wiki.js, DokuWiki, TiddlyWiki, XWiki, and Confluence Server all exist. But in 2026, three platforms have pulled ahead for teams that want modern editing experiences without Confluence's cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docmost&lt;/strong&gt; emerged from the frustration that Outline requires S3 and an external auth provider just to get started. It launched in 2024 and hit 11K stars faster than most tools in the space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outline&lt;/strong&gt; is the established Notion-replacement for teams, backed by a small company with a hosted version. The self-hosted edition is free but source-available under BSL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BookStack&lt;/strong&gt; has been shipping monthly releases since 2015, maintained by a solo developer. It's the most opinionated of the three — fixed hierarchy, simpler UI — but it's the easiest to run.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Full Feature Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Docmost&lt;/th&gt;
&lt;th&gt;Outline&lt;/th&gt;
&lt;th&gt;BookStack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;~11K&lt;/td&gt;
&lt;td&gt;~30K&lt;/td&gt;
&lt;td&gt;~16K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;Business Source License&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language/Stack&lt;/td&gt;
&lt;td&gt;TypeScript (Node.js)&lt;/td&gt;
&lt;td&gt;TypeScript (Node.js)&lt;/td&gt;
&lt;td&gt;PHP (Laravel)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editor&lt;/td&gt;
&lt;td&gt;Block editor (ProseMirror)&lt;/td&gt;
&lt;td&gt;Block editor (ProseMirror)&lt;/td&gt;
&lt;td&gt;WYSIWYG + Markdown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time collab&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spaces/Collections&lt;/td&gt;
&lt;td&gt;Yes (Spaces)&lt;/td&gt;
&lt;td&gt;Yes (Collections)&lt;/td&gt;
&lt;td&gt;Books → Chapters → Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested pages&lt;/td&gt;
&lt;td&gt;Yes (unlimited depth)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Fixed 3-level hierarchy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagrams&lt;/td&gt;
&lt;td&gt;Draw.io native + Mermaid&lt;/td&gt;
&lt;td&gt;Mermaid only&lt;/td&gt;
&lt;td&gt;Draw.io (plugin)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Templates&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-text search&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REST API&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Comprehensive&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhooks&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in auth&lt;/td&gt;
&lt;td&gt;Yes (email/password)&lt;/td&gt;
&lt;td&gt;No (requires OIDC/OAuth)&lt;/td&gt;
&lt;td&gt;Yes (email/password)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LDAP&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (via OIDC only)&lt;/td&gt;
&lt;td&gt;Yes (native LDAP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAML&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OIDC/OAuth&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (required)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3/object storage&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;td&gt;Required (or compatible)&lt;/td&gt;
&lt;td&gt;No (local disk)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dark mode&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile-friendly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import from Notion&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import from Confluence&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Via plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export&lt;/td&gt;
&lt;td&gt;Markdown, PDF&lt;/td&gt;
&lt;td&gt;Markdown, PDF&lt;/td&gt;
&lt;td&gt;PDF, HTML, Markdown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Authentication Deep Dive
&lt;/h2&gt;

&lt;p&gt;Authentication is the biggest friction point when choosing between these three, so it deserves its own section.&lt;/p&gt;

&lt;h3&gt;
  
  
  BookStack
&lt;/h3&gt;

&lt;p&gt;BookStack has full built-in email/password authentication. You can start using it immediately after installation with no external dependencies. For teams that want SSO later, BookStack supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LDAP (built-in)&lt;/li&gt;
&lt;li&gt;SAML 2.0 (built-in)&lt;/li&gt;
&lt;li&gt;OIDC (built-in)&lt;/li&gt;
&lt;li&gt;Social logins (GitHub, Google, etc. via OAuth)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes BookStack the most accessible for teams that don't already have an identity provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docmost
&lt;/h3&gt;

&lt;p&gt;Docmost also includes built-in email/password auth. It additionally supports OIDC for SSO. You can get started without any external auth infrastructure, and add SSO later when your team grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outline
&lt;/h3&gt;

&lt;p&gt;Outline has no built-in email/password login. This is by design — the maintainers want to delegate auth entirely to identity providers. Before your first user can log in, you must configure at least one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google OAuth (simplest if your team uses Google Workspace)&lt;/li&gt;
&lt;li&gt;Slack OAuth&lt;/li&gt;
&lt;li&gt;OIDC (works with Authentik, Keycloak, Logto, Zitadel, etc.)&lt;/li&gt;
&lt;li&gt;Azure AD / Microsoft 365&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams already running an identity provider like Authentik, this is a non-issue. For teams starting fresh, it adds a meaningful setup step. See our &lt;a href="https://dev.to/blog/how-to-self-host-authentik-identity-provider-sso-2026"&gt;guide to self-hosting Authentik&lt;/a&gt; if you want to set up SSO for Outline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Docmost
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;docmost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docmost/docmost:latest&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;APP_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:3000"&lt;/span&gt;
      &lt;span class="na"&gt;APP_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-long-secret-here"&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://docmost:yourpassword@db/docmost"&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://redis:6379"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docmost_data:/app/data/storage&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docmost&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docmost&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yourpassword&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pg_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7.2-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis_data:/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;docmost_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pg_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Outline
&lt;/h3&gt;

&lt;p&gt;Outline requires S3-compatible storage for file uploads. MinIO is the standard self-hosted option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;outline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.getoutline.com/outlinewiki/outline:latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://outline:yourpassword@postgres:5432/outline&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://redis:6379&lt;/span&gt;
      &lt;span class="na"&gt;URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://wiki.yourdomain.com&lt;/span&gt;
      &lt;span class="na"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-64-char-secret-here&lt;/span&gt;
      &lt;span class="na"&gt;UTILS_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-64-char-utils-secret&lt;/span&gt;
      &lt;span class="c1"&gt;# S3 (MinIO)&lt;/span&gt;
      &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio-access-key&lt;/span&gt;
      &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio-secret-key&lt;/span&gt;
      &lt;span class="na"&gt;AWS_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
      &lt;span class="na"&gt;AWS_S3_UPLOAD_BUCKET_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://minio:9000&lt;/span&gt;
      &lt;span class="na"&gt;AWS_S3_UPLOAD_BUCKET_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;outline&lt;/span&gt;
      &lt;span class="na"&gt;AWS_S3_FORCE_PATH_STYLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="c1"&gt;# Auth (one provider required)&lt;/span&gt;
      &lt;span class="na"&gt;OIDC_CLIENT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;outline&lt;/span&gt;
      &lt;span class="na"&gt;OIDC_CLIENT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-oidc-secret&lt;/span&gt;
      &lt;span class="na"&gt;OIDC_AUTH_URI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://auth.yourdomain.com/application/o/authorize/&lt;/span&gt;
      &lt;span class="na"&gt;OIDC_TOKEN_URI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://auth.yourdomain.com/application/o/token/&lt;/span&gt;
      &lt;span class="na"&gt;OIDC_USERINFO_URI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://auth.yourdomain.com/application/o/userinfo/&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;outline&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yourpassword&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;outline&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pg_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio/minio&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server /data --console-address :9001&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio-access-key&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio-secret-key&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;minio_data:/data&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9000:9000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9001:9001"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pg_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minio_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BookStack
&lt;/h3&gt;

&lt;p&gt;BookStack is the simplest to deploy — just an app container and MySQL/MariaDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bookstack&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lscr.io/linuxserver/bookstack:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bookstack&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=America/New_York&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;APP_URL=https://wiki.yourdomain.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;APP_KEY=base64:YOUR_GENERATED_KEY_HERE&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_HOST=bookstack_db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_PORT=3306&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_DATABASE=bookstackapp&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_USERNAME=bookstack&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_PASSWORD=yourpassword&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./bookstack_app:/config&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6875:80"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;bookstack_db&lt;/span&gt;

  &lt;span class="na"&gt;bookstack_db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lscr.io/linuxserver/mariadb:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bookstack_db&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=America/New_York&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_ROOT_PASSWORD=yourrootpassword&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_DATABASE=bookstackapp&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_USER=bookstack&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_PASSWORD=yourpassword&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./bookstack_db:/config&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the APP_KEY with: &lt;code&gt;docker run --rm --entrypoint /bin/sh lscr.io/linuxserver/bookstack -c "APP_KEY= php artisan key:generate --show"&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Content Organization Philosophies
&lt;/h2&gt;

&lt;p&gt;The three tools take fundamentally different approaches to organizing knowledge, and your preference here may be the deciding factor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BookStack: Fixed Hierarchy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;BookStack enforces a strict three-level structure: Books contain Chapters, Chapters contain Pages. You can add a Shelves level above Books. This rigidity is actually a feature for some teams — everyone knows where to put things, and browsing the structure is intuitive for non-technical users. The downside: deeply nested information doesn't map well, and you can't have standalone pages outside a book.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outline: Flat Collections with Nesting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Outline uses Collections (similar to BookStack's Books) but allows arbitrary nesting depth. Pages can be nested inside other pages without a fixed hierarchy. Search is strong enough that strict organization matters less — Outline encourages a "write first, organize later" workflow. Teams migrating from Notion find this familiar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docmost: Spaces with Flexible Structure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Docmost uses Spaces (like Outline's Collections, or BookStack's Books) with unlimited page nesting. The block editor experience is closest to Notion, with slash commands for inserting blocks. The native Draw.io integration for architecture diagrams is a meaningful advantage over Outline (which only supports Mermaid text diagrams).&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Docmost if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want real-time collaboration with a modern block editor&lt;/li&gt;
&lt;li&gt;Draw.io / diagrams.net integration matters (architecture docs, system diagrams)&lt;/li&gt;
&lt;li&gt;You want true AGPL open source (not source-available like Outline's BSL)&lt;/li&gt;
&lt;li&gt;You're willing to accept a less mature project in exchange for faster improvement velocity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose Outline if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You already have an OIDC provider (Authentik, Keycloak, Google Workspace, etc.)&lt;/li&gt;
&lt;li&gt;Search quality and breadth of integrations are top priorities&lt;/li&gt;
&lt;li&gt;You're migrating from Notion and want the closest equivalent experience&lt;/li&gt;
&lt;li&gt;You need a polished API for programmatic doc management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose BookStack if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want the quickest path to a working wiki (no auth provider, no S3)&lt;/li&gt;
&lt;li&gt;Your team is non-technical and benefits from a fixed, predictable structure&lt;/li&gt;
&lt;li&gt;LDAP/SAML integration is needed without an intermediate identity provider&lt;/li&gt;
&lt;li&gt;MIT license is required for organizational or compliance reasons&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;For more on self-hosted documentation tools, see our &lt;a href="https://dev.to/blog/best-open-source-alternatives-to-confluence-2026"&gt;guide to the best open source Confluence alternatives&lt;/a&gt;, our &lt;a href="https://dev.to/blog/self-hosting-guide-outline-2026"&gt;self-hosting guide for Outline&lt;/a&gt;, and our &lt;a href="https://dev.to/blog/how-to-self-host-docmost-2026"&gt;how to self-host Docmost guide&lt;/a&gt;. If you need SSO for Outline, see our &lt;a href="https://dev.to/blog/authentik-vs-keycloak-vs-authelia-2026"&gt;Authentik vs Keycloak vs Authelia comparison&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migrating Between These Tools
&lt;/h2&gt;

&lt;p&gt;Knowledge base migrations are painful but sometimes necessary. Here's what to expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confluence → BookStack&lt;/strong&gt;: The most common migration path. The &lt;code&gt;confluence2bookstack&lt;/code&gt; community tool handles basic content. Tables, page hierarchy, and text content migrate reasonably well. Macros, attachments, and complex formatting require manual cleanup. BookStack's official &lt;a href="https://www.bookstackapp.com/docs/" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; covers the process step by step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confluence → Outline&lt;/strong&gt;: Outline's import handles Confluence HTML exports. Nested page structures flatten into Outline's collection model. Images and attachments require separate handling. Expect to spend time reorganizing content that doesn't map cleanly to flat collections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notion → Docmost or Outline&lt;/strong&gt;: Both tools accept Notion HTML and Markdown exports. The block editor structure is similar enough that most content migrates with reasonable fidelity. Notion databases don't have an equivalent — they become static tables or need to be moved to a dedicated tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Between the three&lt;/strong&gt;: Moving between BookStack, Outline, and Docmost generally requires exporting to Markdown from the source and re-importing. None of the three have direct migration paths between each other, though community scripts exist for common migrations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Permissions and Access Control
&lt;/h2&gt;

&lt;p&gt;For teams, the permission model matters as much as the editing experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BookStack&lt;/strong&gt; has the most granular permission system: you can control read, create, update, and delete access independently at the Shelf, Book, Chapter, and Page level, for both roles and individual users. LDAP/SAML integration means you can sync groups from your directory server to BookStack roles automatically. This makes BookStack the strongest choice for organizations with complex access requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outline&lt;/strong&gt; uses a simpler model: workspace-level roles (Admin, Member, Viewer) plus collection-level permissions (read-only, read-write). Guest access via share links works for external sharing without accounts. The lack of built-in auth means you rely on your OIDC provider's groups to map to Outline roles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docmost&lt;/strong&gt; supports role-based permissions at the Space level. Admin, Member, and Viewer roles exist at the workspace level, with Space-level overrides. The permission model is simpler than BookStack but suitable for most team use cases. SAML and OIDC support allows SSO-based role assignment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance at Scale
&lt;/h2&gt;

&lt;p&gt;All three tools are fast for typical team use (under 1,000 pages, under 100 users). At scale, differences emerge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BookStack&lt;/strong&gt; can handle large page counts well — the MySQL-backed search is fast and well-indexed. PHP can be slow for large page trees if not configured with opcache, but the LinuxServer Docker image handles this. Nginx in front of BookStack is recommended for production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outline&lt;/strong&gt; is fast for editing but search performance depends heavily on PostgreSQL configuration. On large wikis (10,000+ pages), search can become slow without proper Postgres tuning (full-text search indexes). The S3 requirement means file uploads are not bottlenecked by your application server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docmost&lt;/strong&gt; is newer and less tested at large scale. For most teams under 500 pages, performance is excellent. Watch GitHub issues for reports on large-scale performance as the project matures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sources consulted: 7&lt;/li&gt;
&lt;li&gt;GitHub star data from GitHub.com, March 2026&lt;/li&gt;
&lt;li&gt;Docker Compose configs from official documentation for each project&lt;/li&gt;
&lt;li&gt;License information verified from project repositories&lt;/li&gt;
&lt;li&gt;Date: March 2026&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docmost</category>
      <category>outline</category>
      <category>bookstack</category>
      <category>wiki</category>
    </item>
    <item>
      <title>TanStack Query vs SWR vs Apollo Client 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:09:50 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/tanstack-query-vs-swr-vs-apollo-client-2026-3ido</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/tanstack-query-vs-swr-vs-apollo-client-2026-3ido</guid>
      <description>&lt;h1&gt;
  
  
  TanStack Query vs SWR vs Apollo Client 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TanStack Query v5 is the best general-purpose data fetching library for React in 2026 — 12M+ weekly downloads, rich mutation handling, and excellent devtools.&lt;/strong&gt; SWR wins on bundle size (4 KB vs 13 KB) and simplicity for Vercel/Next.js apps with straightforward data needs. Apollo Client remains the gold standard for GraphQL-heavy applications with complex entity relationships — nothing else comes close for normalized graph caching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Query: 12.3M weekly downloads, 48K GitHub stars&lt;/strong&gt; — overtook SWR in 2024 and widened the gap&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SWR: 4.9M weekly downloads, 32K GitHub stars&lt;/strong&gt; — Vercel-backed, 4 KB gzipped, Next.js native&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apollo Client: 1.4M weekly downloads, 47.4K GitHub stars&lt;/strong&gt; — GraphQL specialist, highest complexity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Query is 13.4 KB gzipped&lt;/strong&gt; vs SWR at 4.2 KB — 3x size for 3x feature depth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apollo's normalized cache (InMemoryCache)&lt;/strong&gt; is the best in class for complex GraphQL entity graphs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SWR added useSWRMutation in v2&lt;/strong&gt; but manual optimistic update handling is still more complex than TanStack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Query v5 removed the &lt;code&gt;status === "loading"&lt;/code&gt; state&lt;/strong&gt; — now uses &lt;code&gt;isPending&lt;/code&gt; consistently&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Data Fetching Libraries Matter
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;useEffect&lt;/code&gt; + &lt;code&gt;fetch&lt;/code&gt; leaves you reinventing: loading states, error boundaries, background refetching, cache invalidation, pagination, optimistic updates, and request deduplication. Each of these libraries solves the same core problem with different priorities.&lt;/p&gt;

&lt;p&gt;The API type your backend exposes is the most important selection criterion. REST and tRPC apps thrive with TanStack Query or SWR. GraphQL apps are served best by Apollo Client (or urql — see &lt;a href="https://dev.to/blog/apollo-client-vs-urql-2026"&gt;Apollo Client vs urql 2026&lt;/a&gt;). The choice gets more nuanced when you have complex mutation patterns, real-time requirements, or bundle size constraints.&lt;/p&gt;

&lt;p&gt;One of the most common mistakes teams make is choosing a data fetching library based on its simplest use case. SWR looks appealingly minimal in a tutorial that fetches user data. But tutorials don't show you what happens when you need to optimistically update a list after adding an item, or invalidate a cached query when a related mutation succeeds, or implement infinite scroll with cursor-based pagination. The libraries diverge significantly in these scenarios, and switching later is painful.&lt;/p&gt;

&lt;p&gt;Server components in Next.js App Router have changed the calculus somewhat. For data that can be fetched server-side, you may not need a client-side data fetching library at all — &lt;code&gt;fetch&lt;/code&gt; in a React Server Component with Next.js's built-in caching handles a wide range of use cases. The scenarios where TanStack Query and SWR still shine are: client-side data that depends on user interaction, real-time updates, optimistic UI, and cached data that needs to stay fresh across navigation. Apollo Client remains essential for GraphQL regardless of the server component trend.&lt;/p&gt;

&lt;p&gt;For related tools, see &lt;a href="https://dev.to/blog/tanstack-query-v5-what-changed-migration-guide"&gt;TanStack Query v5 Migration Guide&lt;/a&gt; and &lt;a href="https://dev.to/blog/best-graphql-clients-react-2026"&gt;Best GraphQL Clients for React 2026&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;TanStack Query v5&lt;/th&gt;
&lt;th&gt;SWR v2&lt;/th&gt;
&lt;th&gt;Apollo Client v3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly Downloads&lt;/td&gt;
&lt;td&gt;12.3M&lt;/td&gt;
&lt;td&gt;4.9M&lt;/td&gt;
&lt;td&gt;1.4M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;48K&lt;/td&gt;
&lt;td&gt;32K&lt;/td&gt;
&lt;td&gt;47.4K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle Size (gzipped)&lt;/td&gt;
&lt;td&gt;13.4 KB&lt;/td&gt;
&lt;td&gt;4.2 KB&lt;/td&gt;
&lt;td&gt;~47 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Type&lt;/td&gt;
&lt;td&gt;REST / any&lt;/td&gt;
&lt;td&gt;REST / any&lt;/td&gt;
&lt;td&gt;GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache Strategy&lt;/td&gt;
&lt;td&gt;Query-key based&lt;/td&gt;
&lt;td&gt;URL/key based&lt;/td&gt;
&lt;td&gt;Normalized entity cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mutations&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;useMutation&lt;/code&gt; hook&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useSWRMutation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useMutation&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimistic Updates&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscriptions&lt;/td&gt;
&lt;td&gt;No (use websockets)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Devtools&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSR/Next.js&lt;/td&gt;
&lt;td&gt;Yes (Hydration)&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline support&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  TanStack Query v5
&lt;/h2&gt;

&lt;p&gt;TanStack Query (formerly React Query) is the most fully-featured server state management library for React. v5 shipped with a more consistent API, improved TypeScript generics, and the new &lt;code&gt;suspense&lt;/code&gt; mode built into the core hooks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Basic query&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserProfile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 5 minutes&lt;/span&gt;
    &lt;span class="na"&gt;gcTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 10 minutes (formerly cacheTime)&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Spinner&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Mutation with optimistic updates&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UpdateUserForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PATCH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;

    &lt;span class="na"&gt;onMutate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Cancel in-flight queries for this user&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// Snapshot current state for rollback&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getQueryData&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

      &lt;span class="c1"&gt;// Optimistically update&lt;/span&gt;
      &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setQueryData&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;old&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Roll back on error&lt;/span&gt;
      &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setQueryData&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="na"&gt;onSettled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Always refetch after mutation&lt;/span&gt;
      &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Saving...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Update Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pagination and infinite scroll&lt;/strong&gt; are first-class features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fetchNextPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasNextPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFetchingNextPage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useInfiniteQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pageParam&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchPosts&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageParam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;initialPageParam&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;getNextPageParam&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;lastPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasMore&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TanStack Query's 60% year-over-year download growth tells its own story. The v5 API improvements — unified &lt;code&gt;isPending&lt;/code&gt; status, consistent generics, improved suspense integration — removed several longstanding rough edges. Developers who tried earlier versions and found them confusing often report that v5 is significantly cleaner.&lt;/p&gt;

&lt;p&gt;The devtools are TanStack Query's most underrated feature. The floating devtools panel shows every active query, its cache status, stale/fresh state, last-updated timestamp, and the data itself. When you're debugging why a component is showing stale data, or why a mutation didn't invalidate the right cache entries, the devtools make the answer immediately visible. SWR's devtools are minimal and Apollo's are good but slower to navigate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When TanStack Query is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;REST or tRPC APIs with complex mutation patterns and cache coordination&lt;/li&gt;
&lt;li&gt;Applications needing robust pagination, infinite scroll, or background refetch&lt;/li&gt;
&lt;li&gt;Teams that want the best devtools and debugging experience&lt;/li&gt;
&lt;li&gt;Projects where TypeScript inference quality and type safety are priorities&lt;/li&gt;
&lt;li&gt;Applications with optimistic updates across multiple related queries&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  SWR
&lt;/h2&gt;

&lt;p&gt;SWR (stale-while-revalidate) is Vercel's data fetching library. It follows the HTTP cache control strategy: return cached data immediately, then revalidate in the background. The API is intentionally minimal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Basic fetch — as simple as it gets&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserProfile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSWR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fetcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;revalidateOnFocus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;revalidateOnReconnect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refreshInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Disable polling&lt;/span&gt;
    &lt;span class="na"&gt;dedupingInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Spinner&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Mutations with useSWRMutation (v2)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UpdateUserButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isMutating&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSWRMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;arg&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PATCH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt; &lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isMutating&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isMutating&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Saving...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SWR's focus on simplicity is genuine, not just marketing. The hook API is as small as it can be while still being useful. The stale-while-revalidate strategy means cached data is returned immediately (no loading flash) while a background request updates it silently — this produces a noticeably snappier UX for repeat visits to data-heavy pages.&lt;/p&gt;

&lt;p&gt;SWR's &lt;strong&gt;global config&lt;/strong&gt; and &lt;strong&gt;key-based revalidation&lt;/strong&gt; are its most ergonomic features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// Global config wraps your app&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Manual revalidation from anywhere&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RefreshButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mutate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSWRConfig&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/users/me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Refresh&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SWR's 4.2 KB bundle is its headline advantage. For Next.js apps with simple data needs — user profiles, settings pages, dashboard widgets — SWR is often sufficient and adds minimal overhead. When the data fetching pattern is "fetch this URL and display the result, refresh it occasionally," SWR does that job with the minimum possible code and bundle footprint.&lt;/p&gt;

&lt;p&gt;Where SWR shows its limits is in complex mutations. &lt;code&gt;useSWRMutation&lt;/code&gt; handles basic cases, but coordinating optimistic updates across multiple cache keys, rolling back failed mutations, and maintaining consistency between related queries requires significantly more manual work than with TanStack Query. Teams that start with SWR often find themselves writing custom cache management code that recreates what TanStack Query provides out of the box. If you're building something with heavy writes, compare the mutation examples carefully before choosing SWR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When SWR is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js applications with straightforward server-state needs and limited mutations&lt;/li&gt;
&lt;li&gt;Projects where bundle size is a hard constraint and 4 KB vs 13 KB is material&lt;/li&gt;
&lt;li&gt;Simple GET-heavy interfaces like dashboards, profiles, and settings pages&lt;/li&gt;
&lt;li&gt;Vercel-deployed apps where SWR's native Next.js integration is a team preference&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Apollo Client
&lt;/h2&gt;

&lt;p&gt;Apollo Client is purpose-built for GraphQL. Its &lt;strong&gt;normalized InMemoryCache&lt;/strong&gt; is the technology that justifies its larger bundle (~47 KB gzipped): every entity returned from any query is stored by &lt;code&gt;__typename + id&lt;/code&gt;. When two queries return the same user, one cache entry exists, and updates propagate automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApolloClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/graphql&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InMemoryCache&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;typePolicies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;incoming&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Query&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET_USER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gql&lt;/span&gt;&lt;span class="s2"&gt;`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        id
        title
        createdAt
      }
    }
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserProfile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GET_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Spinner&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Mutation with automatic cache update&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UPDATE_USER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gql&lt;/span&gt;&lt;span class="s2"&gt;`
  mutation UpdateUser($id: ID!, $name: String!) {
    updateUser(id: $id, name: $name) {
      id
      name
    }
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UpdateUserForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;updateUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UPDATE_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Apollo automatically updates cache for matching __typename + id&lt;/span&gt;
    &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;modify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;__typename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;User&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updateUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;
      &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;updateUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;
      &lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;Update&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apollo's normalized cache is worth understanding at a deeper level because it's the feature that most distinguishes it. In a social app, the same User object might appear in 15 different queries — the current user's profile, a list of followers, a comment author, a post author. In TanStack Query, each query stores its own copy of that user. Update the user's name in one mutation, and you need to invalidate all 15 queries to get consistent data. In Apollo, there's one copy of the User object keyed by &lt;code&gt;User:123&lt;/code&gt;. Update it in one mutation, and every query that includes that user reflects the change instantly, without re-fetching. For apps with dense entity graphs — social networks, project management tools, e-commerce catalogs — this is a material architectural advantage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time subscriptions&lt;/strong&gt; are Apollo's exclusive advantage among the three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MESSAGE_SUBSCRIPTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gql&lt;/span&gt;&lt;span class="s2"&gt;`
  subscription OnNewMessage($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id
      content
      author { id name }
      createdAt
    }
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;MessageFeed&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;channelId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MESSAGE_SUBSCRIPTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;channelId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;messageAdded&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apollo's 47 KB bundle is the library's main disadvantage for performance-sensitive applications. For mobile web apps where First Contentful Paint matters, 47 KB is significant. If you're using GraphQL but don't need subscriptions or normalized caching, urql is worth considering — it's ~18 KB and supports both document caching and normalized caching via a plugin. But if you've committed to Apollo Server and Apollo Studio on the backend, staying in the Apollo ecosystem for the client has real benefits in tooling coherence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Apollo Client is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GraphQL APIs with complex entity relationships where normalized caching provides real value&lt;/li&gt;
&lt;li&gt;Applications requiring real-time subscriptions over WebSocket or SSE&lt;/li&gt;
&lt;li&gt;Teams where the Apollo ecosystem (Apollo Server, Apollo Studio, Apollo Federation) is already in use&lt;/li&gt;
&lt;li&gt;Projects with large shared entity sets where cache normalization prevents stale data problems&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose TanStack Query&lt;/strong&gt; as your default for REST and tRPC applications. It handles the full lifecycle — background refetch, optimistic updates, pagination, and mutations — better than any competitor. The 13 KB bundle overhead is well justified by what you get. At 12M+ weekly downloads and 48K GitHub stars, it's also the industry standard in 2026, meaning you'll find tutorials, Stack Overflow answers, and team members who know it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose SWR&lt;/strong&gt; for simpler Next.js apps where bundle size matters and your mutation patterns don't require TanStack Query's full feature set. SWR's stale-while-revalidate pattern is perfectly suited for most dashboard and profile page patterns. If the primary data fetching concern is "keep this page fresh while the user navigates," SWR is elegant and minimal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Apollo Client&lt;/strong&gt; when GraphQL is your primary API and you have complex entity relationships that benefit from normalized caching. Don't use Apollo for REST — TanStack Query is significantly better suited. If you need GraphQL but want a smaller bundle, evaluate urql as an alternative before defaulting to Apollo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The React Server Component wildcard:&lt;/strong&gt; As Next.js App Router and server components mature, some teams are finding they need client-side data fetching libraries for fewer queries. Server components handle the initial data load; client components use TanStack Query or SWR only for mutations and real-time updates. This hybrid approach is gaining traction and affects the relative importance of bundle size — if only 20% of queries happen on the client, a 13 KB vs 4 KB difference is less significant than it was in the pages-router era.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cache Invalidation: The Critical Difference
&lt;/h2&gt;

&lt;p&gt;Cache invalidation is the hardest problem in data fetching libraries, and the three tools approach it very differently. Understanding this difference often clarifies the selection decision.&lt;/p&gt;

&lt;p&gt;TanStack Query uses explicit cache key invalidation: &lt;code&gt;queryClient.invalidateQueries({ queryKey: ["user", userId] })&lt;/code&gt; marks a query stale and triggers a background refetch. This is manual but predictable — you control exactly what refetches when. The query key structure is your cache key design, and good key design makes invalidation straightforward.&lt;/p&gt;

&lt;p&gt;SWR uses URL-based keys and provides &lt;code&gt;mutate(key)&lt;/code&gt; for invalidation. The simplicity is the point — most SWR apps use a URL as the key, so invalidating after a mutation to &lt;code&gt;/api/users/123&lt;/code&gt; means calling &lt;code&gt;mutate("/api/users/123")&lt;/code&gt;. This works naturally for simple cases but becomes awkward when multiple different queries might return the same resource under different keys.&lt;/p&gt;

&lt;p&gt;Apollo's normalized cache uses entity-level invalidation. You don't invalidate queries — you modify entities in the cache and Apollo propagates those changes to every query that included that entity. This is more sophisticated but also more opaque: when something doesn't update correctly, debugging requires understanding the cache's normalization logic. Apollo's cache modification API (&lt;code&gt;cache.modify&lt;/code&gt;, &lt;code&gt;cache.evict&lt;/code&gt;) is powerful but has a steeper learning curve than TanStack Query's query invalidation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;Download statistics from npm trends and TanStack's own npm stats page (March 2026). Bundle sizes from Bundlephobia. GitHub stars from repository pages. Feature comparison based on official documentation for TanStack Query v5.90, SWR v2.3, and Apollo Client v3.12. Benchmark data on TanStack Query growth sourced from TanStack official npm statistics dashboard.&lt;/p&gt;

</description>
      <category>tanstackquery</category>
      <category>swr</category>
      <category>apolloclient</category>
      <category>react</category>
    </item>
    <item>
      <title>Turborepo vs Nx vs Moon: Monorepo Tools 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:09:07 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/turborepo-vs-nx-vs-moon-monorepo-tools-2026-gc4</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/turborepo-vs-nx-vs-moon-monorepo-tools-2026-gc4</guid>
      <description>&lt;h1&gt;
  
  
  Turborepo vs Nx vs Moon: Monorepo Tools 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Turborepo is the right default for most JavaScript/TypeScript monorepos — simple setup, excellent caching, and Vercel Remote Cache.&lt;/strong&gt; Nx wins for enterprise teams that need a full monorepo platform with code generators, affected-command intelligence, and first-class Angular/React support. Moon is the sleeper pick for polyglot repos (Node.js + Rust + Go) or teams that want reproducible toolchain management baked in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nx: ~5M weekly downloads&lt;/strong&gt; — richest ecosystem, generators, affected commands, Nx Cloud&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo: ~2M weekly downloads&lt;/strong&gt; — simplest setup, Vercel-backed, Rust-based task runner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moon: ~50K weekly downloads&lt;/strong&gt; — Rust-based, polyglot support, built-in toolchain management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo can reduce build times by 70%&lt;/strong&gt; in large JS/TS monorepos with remote caching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nx benchmarks show 7x better performance&lt;/strong&gt; than Turborepo in large-scale open-source tests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moon manages language versions automatically&lt;/strong&gt; — no more mismatched Node.js across machines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All three support remote caching&lt;/strong&gt; — Vercel, Nx Cloud, and moonrepo.dev respectively&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Monorepo Build Problem
&lt;/h2&gt;

&lt;p&gt;Without a build orchestration tool, a 50-package monorepo rebuilds everything on every CI run. With task caching, only packages with changed inputs rebuild. At scale, this difference is 10-50x in CI time.&lt;/p&gt;

&lt;p&gt;The three tools in this comparison take different approaches to that core problem: Turborepo optimizes for simplicity and speed, Nx for completeness and enterprise scale, and Moon for reproducibility and polyglot correctness.&lt;/p&gt;

&lt;p&gt;Understanding what these tools actually do is important before choosing one. They are not workspace managers — pnpm workspaces, npm workspaces, and Yarn workspaces handle package installation and linking. What Turborepo, Nx, and Moon add on top is task orchestration: knowing which tasks depend on which other tasks, caching the outputs of tasks that haven't changed, and running tasks in the right order with maximum parallelism. A monorepo without one of these tools will still work — but every CI run will be slow, and developers will spend time waiting for builds that don't need to run.&lt;/p&gt;

&lt;p&gt;The "remote caching" feature is where the real ROI materializes. Without remote caching, developer A's cached builds aren't shared with developer B or with CI. With remote caching, if CI already built and tested a package on the main branch, the next developer who pulls that code gets the cached output immediately — zero rebuild time. For large teams, remote caching can eliminate 80-90% of total build time.&lt;/p&gt;

&lt;p&gt;For broader monorepo context, see &lt;a href="https://dev.to/blog/how-to-set-up-monorepo-turborepo-2026"&gt;How to Set Up a Monorepo with Turborepo 2026&lt;/a&gt; and &lt;a href="https://dev.to/blog/turborepo-vs-nx-monorepo-2026"&gt;Turborepo vs Nx Monorepo 2026&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Turborepo&lt;/th&gt;
&lt;th&gt;Nx&lt;/th&gt;
&lt;th&gt;Moon&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly Downloads&lt;/td&gt;
&lt;td&gt;~2M&lt;/td&gt;
&lt;td&gt;~5M&lt;/td&gt;
&lt;td&gt;~50K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Written In&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;TypeScript (Rust core in progress)&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote Cache&lt;/td&gt;
&lt;td&gt;Vercel Remote Cache&lt;/td&gt;
&lt;td&gt;Nx Cloud&lt;/td&gt;
&lt;td&gt;moonrepo.dev&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code Generators&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affected Commands&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Advanced&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polyglot Support&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Yes (Node, Rust, Go)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Toolchain Mgmt&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (auto version mgmt)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config Complexity&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium-High&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing (remote cache)&lt;/td&gt;
&lt;td&gt;Free (self-host) / Vercel&lt;/td&gt;
&lt;td&gt;Free tier / Nx Cloud&lt;/td&gt;
&lt;td&gt;Free tier / moonrepo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Turborepo
&lt;/h2&gt;

&lt;p&gt;Turborepo (acquired by Vercel in 2021) is the gateway drug of monorepo tooling. The &lt;code&gt;turbo.json&lt;/code&gt; configuration is minimal, the caching is automatic, and setup from a standard pnpm workspace takes under an hour.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;turbo.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://turbo.build/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"package.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsconfig.json"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dist/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tests/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eslint.config.js"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"persistent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run builds across all packages (cached)&lt;/span&gt;
npx turbo build

&lt;span class="c"&gt;# Run only affected packages (based on git diff)&lt;/span&gt;
npx turbo build &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...[HEAD^1]

&lt;span class="c"&gt;# Remote cache via Vercel&lt;/span&gt;
npx turbo login
npx turbo &lt;span class="nb"&gt;link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turborepo's caching model is file-hash based: if the inputs (source files, env vars, package.json) haven't changed, the outputs are restored from cache. Remote cache allows teams to share this across CI runs and developer machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package filtering is Turborepo's daily workflow tool:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Only the web app and its dependencies&lt;/span&gt;
npx turbo build &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@acme/web...

&lt;span class="c"&gt;# Only packages changed since main&lt;/span&gt;
npx turbo &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...[origin/main]

&lt;span class="c"&gt;# Exclude a package&lt;/span&gt;
npx turbo build &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=!&lt;/span&gt;@acme/legacy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What Turborepo doesn't have:&lt;/strong&gt; code generators, advanced project graph visualization, or first-class framework scaffolding. It's a task runner with caching — excellent at that, nothing more. This scope limitation is deliberate. The Turborepo team believes the tool should stay focused on task orchestration and let pnpm workspaces handle package management and developers handle code generation via other tools. Whether this is a strength or a weakness depends entirely on your team's needs.&lt;/p&gt;

&lt;p&gt;Turborepo's integration with Vercel Remote Cache deserves special mention. If you're deploying to Vercel, remote caching is free and zero-configuration — &lt;code&gt;turbo login &amp;amp;&amp;amp; turbo link&lt;/code&gt; and you're done. Builds from CI share caches with local development. Vercel also caches Turborepo outputs as part of build steps, meaning preview deployment builds can reuse the CI cache. This tight integration is a genuine advantage for Vercel users that Nx and Moon can't match.&lt;/p&gt;

&lt;p&gt;The Rust rewrite (from Go, completed in 2024) improved Turborepo's performance significantly. Hash computation, file scanning, and graph traversal are all faster. For repos with thousands of files, the cold-start time (no cache hit) improved by 30-40%. Hot-path performance (most tasks cached) was already fast and remained so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Turborepo is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JavaScript/TypeScript-only monorepos starting from scratch&lt;/li&gt;
&lt;li&gt;Teams that want minimal configuration overhead and fast onboarding&lt;/li&gt;
&lt;li&gt;Vercel-deployed projects where native Remote Cache integration is a hard advantage&lt;/li&gt;
&lt;li&gt;Repos with 5-50 packages where Nx's feature depth isn't needed or wanted&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Nx
&lt;/h2&gt;

&lt;p&gt;Nx (by Nrwl) is the most feature-complete monorepo solution in the JavaScript ecosystem. It's been the enterprise standard for large Angular and React codebases for years and has expanded to support virtually every modern framework.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;nx.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./node_modules/nx/schemas/nx-schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defaultBase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targetDefaults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^production"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{workspaceRoot}/jest.preset.js"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"namedInputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"{projectRoot}/**/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sharedGlobals"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate a new React library&lt;/span&gt;
nx generate @nx/react:library ui-components &lt;span class="nt"&gt;--bundler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vite

&lt;span class="c"&gt;# Run affected tests (smarter than Turborepo's filter)&lt;/span&gt;
nx affected:test &lt;span class="nt"&gt;--base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;main

&lt;span class="c"&gt;# Visualize the project graph&lt;/span&gt;
nx graph

&lt;span class="c"&gt;# Migrate to latest Nx version (automated)&lt;/span&gt;
nx migrate latest &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; nx migrate &lt;span class="nt"&gt;--run-migrations&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nx's &lt;strong&gt;affected commands&lt;/strong&gt; are more sophisticated than Turborepo's filters: Nx builds a full dependency graph and knows exactly which projects are affected by any file change, including through transitive dependencies. This can mean running 3 packages instead of 15 in a large monorepo. Turborepo's &lt;code&gt;--filter=...[HEAD^1]&lt;/code&gt; is simpler and covers most cases, but Nx's affected analysis uses deeper dependency tracking that can be more accurate in complex repos where a shared utility is consumed by many packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nx Cloud&lt;/strong&gt; provides distributed task execution — tasks fan out across multiple agents automatically, with no manual CI matrix configuration. For large teams, this can reduce CI time from 30 minutes to 5. The Nx Cloud free tier covers small teams, and enterprise pricing is per-seat for larger organizations. This pricing model makes Nx Cloud an easier sell than Turborepo Remote Cache at scale, since Turborepo's remote cache is free on Vercel but requires a paid plan or self-hosting for non-Vercel deployments.&lt;/p&gt;

&lt;p&gt;Nx's code generators (called "generators" or formerly "schematics") are its most powerful differentiator for large teams. Running &lt;code&gt;nx generate @nx/react:library ui-components&lt;/code&gt; creates the library, sets up the project configuration, adds it to the workspace graph, and generates test scaffolding — all in seconds. For organizations that create dozens of new packages per year, this consistency is significant. Without generators, new packages are created manually, leading to drift in how packages are structured, tested, and bundled.&lt;/p&gt;

&lt;p&gt;The Nx migration story is also a practical advantage. If you start with Turborepo and outgrow it, &lt;code&gt;nx migrate&lt;/code&gt; has tooling to help. Nx maintains a Turborepo-to-Nx migration guide with automated transforms. This migration path is documented, tested, and used by teams that started simple and grew into Nx's feature set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Nx is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Large teams (10+ developers) who benefit from code generators and standardized project structure&lt;/li&gt;
&lt;li&gt;Angular or NestJS projects (Nx has the best Angular support in the ecosystem)&lt;/li&gt;
&lt;li&gt;Repos where affected-command intelligence meaningfully reduces CI scope&lt;/li&gt;
&lt;li&gt;Organizations willing to invest in Nx Cloud for distributed execution across CI agents&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Moon
&lt;/h2&gt;

&lt;p&gt;Moon (by moonrepo) is the newest of the three and solves a different class of problems: reproducibility and polyglot correctness. Its killer feature is &lt;strong&gt;toolchain management&lt;/strong&gt; — Moon installs and pins Node.js, pnpm, Bun, Deno, and even Rust and Go versions for each workspace, ensuring every developer and CI run uses identical versions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .moon/workspace.yml&lt;/span&gt;
&lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps/*"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;packages/*"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services/*"&lt;/span&gt;

&lt;span class="na"&gt;vcs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;manager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git&lt;/span&gt;
  &lt;span class="na"&gt;defaultBranch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .moon/toolchain.yml&lt;/span&gt;
&lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;22.11.0"&lt;/span&gt;
  &lt;span class="na"&gt;packageManager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pnpm&lt;/span&gt;
  &lt;span class="na"&gt;pnpmVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9.15.0"&lt;/span&gt;

&lt;span class="na"&gt;rust&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.82.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# moon.yml (per-project)&lt;/span&gt;
&lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;typescript&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;library&lt;/span&gt;

&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tsc --build&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/**/*"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tsconfig.json"&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dist"&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vitest run&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/**/*"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/**/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run tasks across all projects&lt;/span&gt;
moon run :build

&lt;span class="c"&gt;# Run for specific project&lt;/span&gt;
moon run web:build

&lt;span class="c"&gt;# Check affected projects&lt;/span&gt;
moon ci &lt;span class="nt"&gt;--base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;main &lt;span class="nt"&gt;--head&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Moon's remote caching (moonrepo.dev) is free for open-source and affordable for teams. The Rust implementation ensures fast graph traversal even in massive repos.&lt;/p&gt;

&lt;p&gt;Moon's approach to the Node.js version problem is worth explaining more concretely. The &lt;code&gt;.moon/toolchain.yml&lt;/code&gt; file declares the exact Node.js version (and package manager version) the workspace requires. Moon installs and manages these versions automatically — similar to &lt;code&gt;nvm&lt;/code&gt; or &lt;code&gt;volta&lt;/code&gt;, but integrated into the build system and applied consistently across all tasks. This means you can't accidentally run a build with Node.js 20 when the project requires Node.js 22, because Moon is managing the runtime. For teams where "works on my machine" CI failures have been a recurring problem, this automatic toolchain management is a significant quality-of-life improvement.&lt;/p&gt;

&lt;p&gt;The polyglot story is Moon's most unique feature. A workspace that has JavaScript services, a Rust CLI tool, and Go microservices can all be managed in one &lt;code&gt;moon.yml&lt;/code&gt; dependency graph. Moon knows which Rust services need to be rebuilt when a shared type library changes, and which JavaScript services depend on the compiled Rust CLI. Turborepo and Nx are fundamentally JavaScript-centric tools; Moon is language-agnostic by design.&lt;/p&gt;

&lt;p&gt;Moon's smaller community (50K weekly downloads vs Turborepo's 2M) is a practical consideration. There are fewer tutorials, fewer GitHub issues documenting edge cases, and fewer team members who will already know the tool when you hire them. The trade is worth making if your use case genuinely requires polyglot support or toolchain management — but for JavaScript-only repos, the community size difference is a real cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Moon is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Polyglot monorepos mixing Node.js, Rust, Go, or Python in the same dependency graph&lt;/li&gt;
&lt;li&gt;Teams burned repeatedly by Node.js or package manager version mismatches across machines&lt;/li&gt;
&lt;li&gt;Projects where deterministic builds are a compliance or reliability requirement&lt;/li&gt;
&lt;li&gt;CI environments where toolchain reproducibility needs to be guaranteed, not assumed&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Turborepo&lt;/strong&gt; as your default for JavaScript/TypeScript monorepos. It's the simplest path to caching and the easiest to maintain. If you're on Vercel, the free remote cache seals the deal. If you outgrow it, migrating to Nx has official tooling and is well-documented. Don't overthink the initial choice — Turborepo's simplicity means the cost of being wrong is low.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Nx&lt;/strong&gt; when you need a true monorepo platform — generators, affected intelligence, distributed task execution, and first-class framework support. The configuration cost is justified at scale. If your organization is creating multiple new packages per month, Nx's generators pay for themselves quickly in consistency and reduced bootstrapping time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Moon&lt;/strong&gt; when language reproducibility matters more than ecosystem breadth, or when your repo spans multiple programming languages. It's the most principled approach to the "works on my machine" problem, and its toolchain management feature alone is worth evaluating if you've had Node.js version drift issues in CI.&lt;/p&gt;

&lt;p&gt;The decision gets easier if you frame it as a progression: most teams should start with Turborepo, evaluate Nx when generators and affected commands become important, and consider Moon if polyglot support or strict toolchain reproducibility becomes a requirement. These are not competitors fighting for the same team — they serve different stages and different types of monorepos.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;Download statistics from npm trends (March 2026). Performance benchmarks sourced from the Nx large-monorepo benchmark repository (vsavkin/large-monorepo) and Turborepo documentation. Feature comparison based on official documentation for Turborepo 2.x, Nx 21.x, and Moon 1.x. Remote caching pricing reflects free-tier availability as of publication date.&lt;/p&gt;

</description>
      <category>turborepo</category>
      <category>nx</category>
      <category>moon</category>
      <category>monorepo</category>
    </item>
    <item>
      <title>Effect-TS vs fp-ts vs Neverthrow: TS Errors 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:09:04 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/effect-ts-vs-fp-ts-vs-neverthrow-ts-errors-2026-3li0</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/effect-ts-vs-fp-ts-vs-neverthrow-ts-errors-2026-3li0</guid>
      <description>&lt;h1&gt;
  
  
  Effect-TS vs fp-ts vs Neverthrow: TS Errors 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Neverthrow is the pragmatic pick for teams adopting typed errors without a paradigm shift.&lt;/strong&gt; Effect-TS is the most powerful option — a full async runtime, typed errors, dependency injection, and structured concurrency — but demands a steep learning investment. fp-ts is a battle-tested functional programming toolkit that's being superseded by Effect in most new projects. Start with Neverthrow; graduate to Effect when complexity justifies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;fp-ts: 3.7M weekly downloads, 11.5K GitHub stars&lt;/strong&gt; — widely used but new projects increasingly choose Effect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neverthrow: 1.3M weekly downloads, 7.2K GitHub stars&lt;/strong&gt; — simple Result type, low overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Effect-TS: ~13.6K GitHub stars&lt;/strong&gt; — growing rapidly; v4 beta dropped bundle from ~70 KB to ~20 KB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Effect v4 is now in beta&lt;/strong&gt; as of February 2026 with major bundle size improvements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neverthrow is no longer actively maintained&lt;/strong&gt; — PRs go unreviewed for months&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fp-ts and Effect share the same core team&lt;/strong&gt; — Effect is the official successor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All three solve the same core problem&lt;/strong&gt;: making failure explicit in the type system&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Problem: TypeScript's Silent Failure Mode
&lt;/h2&gt;

&lt;p&gt;JavaScript's &lt;code&gt;throw&lt;/code&gt; is invisible to the type system. A function typed as &lt;code&gt;Promise&amp;lt;User&amp;gt;&lt;/code&gt; can throw network errors, validation errors, or database failures — none of which appear in the return type. TypeScript pretends everything succeeds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Standard TypeScript — failure is invisible&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// could throw&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or return null, or throw...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The caller has no idea this can fail&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// what could go wrong? TypeScript says: nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three libraries in this comparison solve this differently: Neverthrow with a minimal &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt; type, fp-ts with a full functional programming toolkit, and Effect-TS with an entire async runtime.&lt;/p&gt;

&lt;p&gt;This problem isn't theoretical. Untyped error handling is one of the leading causes of runtime failures in TypeScript applications. A function that throws a network timeout gets called by a function that calls another function — and somewhere five layers up, there's a catch clause that logs "Unknown error" and swallows the context. Typed errors make the failure modes explicit at every level of the call stack, so callers can handle specific error types and the compiler enforces that every error path is addressed.&lt;/p&gt;

&lt;p&gt;The tradeoff is verbosity. Typed error handling requires more code than &lt;code&gt;try/catch&lt;/code&gt; and forces you to thread error types through your function signatures. Teams new to this pattern often find it feels heavy for simple CRUD operations. The libraries in this comparison exist on a spectrum from "minimal overhead" (Neverthrow) to "maximum expressiveness at maximum complexity" (Effect-TS), and picking the right level of investment is the central question.&lt;/p&gt;

&lt;p&gt;For related TypeScript patterns, see &lt;a href="https://dev.to/blog/neverthrow-vs-effect-ts-vs-oxide-ts-result-types-typescript-2026"&gt;Neverthrow vs Effect-TS vs oxide-ts: Result Types&lt;/a&gt; and &lt;a href="https://dev.to/blog/effect-ts-vs-fp-ts-2026"&gt;Effect-TS vs fp-ts 2026&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Effect-TS v4&lt;/th&gt;
&lt;th&gt;fp-ts v2&lt;/th&gt;
&lt;th&gt;Neverthrow 0.x&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly Downloads&lt;/td&gt;
&lt;td&gt;~200K&lt;/td&gt;
&lt;td&gt;3.7M&lt;/td&gt;
&lt;td&gt;1.3M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;13.6K&lt;/td&gt;
&lt;td&gt;11.5K&lt;/td&gt;
&lt;td&gt;7.2K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle Size&lt;/td&gt;
&lt;td&gt;~20 KB (v4)&lt;/td&gt;
&lt;td&gt;~150 KB&lt;/td&gt;
&lt;td&gt;~3 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paradigm&lt;/td&gt;
&lt;td&gt;Full runtime + FP&lt;/td&gt;
&lt;td&gt;FP toolkit&lt;/td&gt;
&lt;td&gt;Minimal Result type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed errors&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (Either)&lt;/td&gt;
&lt;td&gt;Yes (Result)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Async handling&lt;/td&gt;
&lt;td&gt;Built-in fiber runtime&lt;/td&gt;
&lt;td&gt;Task, TaskEither&lt;/td&gt;
&lt;td&gt;ResultAsync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency injection&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active maintenance&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Maintenance mode&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard adoption&lt;/td&gt;
&lt;td&gt;Growing fast&lt;/td&gt;
&lt;td&gt;Established&lt;/td&gt;
&lt;td&gt;Stable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Neverthrow
&lt;/h2&gt;

&lt;p&gt;Neverthrow provides one thing: a &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt; type with chainable methods. It's the smallest conceptual leap from standard TypeScript — no new paradigms, no unfamiliar operators.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;UserError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DB_ERROR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;err&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NOT_FOUND&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Chaining&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapErr&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`Failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Pattern matching&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOk&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ResultAsync&lt;/code&gt; handles promises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ResultAsync&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ResultAsync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromPromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;UserError&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DB_ERROR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Compose async Results&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;andThen&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchUserPreferences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The maintenance concern is real.&lt;/strong&gt; Neverthrow's maintainer has been less active since late 2024, with open PRs aging. The library is stable — it's a small codebase with a well-defined scope — but teams starting new projects should weigh this against Effect's active development. Neverthrow's API is stable precisely because it doesn't try to do much. A library that doesn't change isn't necessarily abandoned; it might just be done. Whether that's acceptable depends on your risk tolerance for third-party dependencies.&lt;/p&gt;

&lt;p&gt;Neverthrow's approach to composition is worth understanding deeply before committing. The &lt;code&gt;andThen&lt;/code&gt;, &lt;code&gt;map&lt;/code&gt;, &lt;code&gt;mapErr&lt;/code&gt;, and &lt;code&gt;orElse&lt;/code&gt; methods enable Railway-Oriented Programming — a pattern where you chain transformations and error handlers without nesting. At its best, this produces very readable code where the happy path flows left-to-right and errors are handled at natural branch points. At scale, however, complex chains can become difficult to debug because intermediate states aren't easily inspected. Effect-TS has better observability tools for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Neverthrow is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Teams adopting typed errors incrementally without a full paradigm shift&lt;/li&gt;
&lt;li&gt;Simple CRUD apps where Railway-Oriented Programming covers the error patterns&lt;/li&gt;
&lt;li&gt;Avoiding fp-ts/Effect's learning curve is a hard requirement for the team&lt;/li&gt;
&lt;li&gt;Existing codebases adding typed error handling without a full refactor&lt;/li&gt;
&lt;li&gt;Small libraries or utility packages where a minimal dependency is important&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  fp-ts
&lt;/h2&gt;

&lt;p&gt;fp-ts brings Haskell-style functional programming to TypeScript: Option, Either, TaskEither, IO, Reader, and the full algebraic data type toolkit. It was the gold standard for typed FP in TypeScript for years.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ApiError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;TE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TaskEither&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;TE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tryCatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ApiError&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUserDisplayName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;TE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TaskEither&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;TE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;TE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Display name fetch failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Run the computation&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUserDisplayName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isRight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;fp-ts's &lt;code&gt;pipe&lt;/code&gt; function is central to everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;processUsers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;O&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;O&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;O&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;none&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;fp-ts in 2026:&lt;/strong&gt; The library is in maintenance mode. The core fp-ts team has moved their energy to Effect-TS, which they consider the spiritual successor. fp-ts v2 receives bug fixes but no new features. For teams already invested in fp-ts, it continues to work well — but for new projects, Effect is the recommended path. The migration from fp-ts to Effect is non-trivial but possible, and the Effect team has published guides for teams making the transition.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pipe&lt;/code&gt; function from fp-ts deserves special mention because it's genuinely influential. The pattern of threading a value through a series of transformations — without the intermediate variable assignment of imperative code — produces a particular kind of readable functional code. Many developers who encounter fp-ts for the first time through &lt;code&gt;pipe&lt;/code&gt; end up adopting it even if they don't use the rest of the library. Effect has the same pattern, so fp-ts's core idiom carries over.&lt;/p&gt;

&lt;p&gt;Bundle size is fp-ts's most significant disadvantage at ~150 KB. This is primarily a problem for frontend applications. For Node.js backends, 150 KB of runtime code is not meaningful. For frontend bundles where every KB affects Time to Interactive, fp-ts is a poor choice. Teams that want functional error handling on the frontend should look at Neverthrow (3 KB) or Valibot with custom Result types instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When fp-ts is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing fp-ts codebases that are stable, well-tested, and deeply understood by the team&lt;/li&gt;
&lt;li&gt;Teams with strong functional programming background who prefer fp-ts's minimal scope&lt;/li&gt;
&lt;li&gt;Projects using fp-ts's Option and Array utilities extensively where Effect's approach differs&lt;/li&gt;
&lt;li&gt;When you want to incrementally migrate to Effect — fp-ts and Effect share conceptual DNA&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Effect-TS
&lt;/h2&gt;

&lt;p&gt;Effect-TS is not just an error handling library — it's a complete TypeScript runtime for building production applications. Think of it as an answer to: what if TypeScript had structured concurrency, typed errors, dependency injection, resource management, and observability built in?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// Three type parameters: success, error, requirements (dependencies)&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;UserEffect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserNotFound&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;DatabaseError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserNotFound&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;DatabaseError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tryPromise&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DatabaseError&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;_tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DatabaseError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;succeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserNotFound&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;_tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UserNotFound&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Composing effects&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUserWithPermissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;findUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Effect v4 (beta, February 2026) brings the bundle down from ~70 KB to ~20 KB for a minimal program including Stream and Schema — a major improvement for adoption concerns. The v4 team has focused heavily on making Effect more accessible: better error messages, cleaner stack traces, improved documentation, and the bundle size improvement that makes frontend usage more viable. Effect v4 is the version where the library crosses from "impressive but too heavy for most projects" to "worth seriously evaluating for complex backend applications."&lt;/p&gt;

&lt;p&gt;The learning curve for Effect is real and shouldn't be underestimated. The three-parameter generic &lt;code&gt;Effect&amp;lt;Success, Error, Requirements&amp;gt;&lt;/code&gt; is unfamiliar to most TypeScript developers. The &lt;code&gt;pipe&lt;/code&gt;-heavy style requires comfort with function composition. The concept of a "fiber" (Effect's concurrency primitive) introduces new mental models for async code. Teams that have successfully adopted Effect report 2-4 weeks before developers feel productive, and 2-3 months before the patterns feel natural. Companies like Harbor have written publicly about choosing not to adopt Effect because the learning investment exceeded the benefit for their use case — that's a legitimate outcome, not a failure of the library.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Effect's dependency injection&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;UserRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GenericTag&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UserRepository&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LiveUserRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;succeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Test implementation&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TestUserRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;succeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Test User&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Provide dependencies at the boundary&lt;/span&gt;
&lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runPromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getUserWithPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Effect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LiveUserRepository&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When Effect-TS is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complex backend services where structured concurrency and resource management matter&lt;/li&gt;
&lt;li&gt;Teams willing to invest in the learning curve (expect 2-4 weeks to productive velocity)&lt;/li&gt;
&lt;li&gt;Applications needing built-in retry, timeout, and circuit breaker patterns&lt;/li&gt;
&lt;li&gt;Projects where testability through dependency injection is a priority&lt;/li&gt;
&lt;li&gt;Long-lived services where the operational benefits (observability, structured errors, resource cleanup) justify the upfront complexity cost&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Neverthrow&lt;/strong&gt; for incremental adoption — it's the smallest step toward typed errors and works with any existing TypeScript codebase. The 3 KB bundle fits anywhere, the API is learnable in an afternoon, and the Result type integrates cleanly with existing code. Accept the reduced maintenance activity for stable, well-scoped codebases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose fp-ts&lt;/strong&gt; only if you're maintaining an existing fp-ts codebase with a team that knows it well. For new projects in 2026, Effect-TS is the better investment at the same learning curve. The fp-ts team's own guidance is to evaluate Effect for new projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Effect-TS&lt;/strong&gt; when building complex, long-lived backend services and your team can absorb the learning curve. Effect v4's improved bundle size removes the last major adoption barrier. The expressiveness payoff is real for sufficiently complex systems — teams that have adopted it often describe it as making previously-difficult problems (retry with exponential backoff, request cancellation, complex concurrent workflows) feel straightforward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest answer for most teams:&lt;/strong&gt; Start with Neverthrow. Add typed errors to your 10 most important functions. Learn what it feels like to have failure modes visible in your type signatures. If you find yourself wanting more — dependency injection, structured concurrency, schema validation as part of the same ecosystem — then evaluate Effect. The transition from Neverthrow-style thinking to Effect-style thinking is a manageable conceptual leap.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on the Result Pattern vs Exceptions
&lt;/h2&gt;

&lt;p&gt;One question teams often ask when evaluating these libraries: should we go all-in on typed errors for every function, or only for functions that can "meaningfully fail"? The answer that works best in practice is selective adoption — use typed errors at service boundaries and external API calls, where failure modes are varied and callers need to handle them explicitly. Keep exceptions for truly unexpected errors (programming bugs, out-of-memory, etc.) that represent broken invariants rather than expected failure cases. This hybrid approach avoids the "Result for everything" trap where simple getters and pure functions become verbose for no benefit.&lt;/p&gt;

&lt;p&gt;All three libraries support this hybrid approach. Neverthrow's &lt;code&gt;fromThrowable&lt;/code&gt; utility wraps exception-throwing functions into Result-returning functions. fp-ts's &lt;code&gt;tryCatch&lt;/code&gt; does the same. Effect's &lt;code&gt;Effect.tryPromise&lt;/code&gt; and &lt;code&gt;Effect.try&lt;/code&gt; wrap throwing code at the boundary. The pattern is: keep your own code typed-error-aware, wrap third-party libraries at the edges, and let exceptions propagate from true bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;Download statistics from npm trends (March 2026). GitHub stars from repository pages. Bundle sizes from Bundlephobia and official Effect v4 release notes (effect.website blog). Maintenance status assessed from GitHub commit activity and open PR response times over the 6 months prior to publication.&lt;/p&gt;

</description>
      <category>effectts</category>
      <category>fpts</category>
      <category>neverthrow</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Biome vs ESLint vs Oxlint: JS Linters 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:08:19 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/biome-vs-eslint-vs-oxlint-js-linters-2026-408h</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/biome-vs-eslint-vs-oxlint-js-linters-2026-408h</guid>
      <description>&lt;h1&gt;
  
  
  Biome vs ESLint vs Oxlint: JS Linters 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ESLint v9 remains the safe default — 50M+ weekly downloads and an irreplaceable plugin ecosystem.&lt;/strong&gt; Biome is the best choice for greenfield projects that want one fast tool for linting and formatting (no Prettier needed). Oxlint is the fastest raw linter at 50-100x ESLint's speed, but it's linting-only with ~300 rules and no auto-fix for most violations — use it as a CI speed layer, not a replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ESLint: 50M+ weekly downloads&lt;/strong&gt; — universal framework support, 700+ rules, 4000+ plugins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Biome: ~1.5M weekly downloads&lt;/strong&gt; — 423+ rules, built-in formatter, Rust-based, 10-20x faster than ESLint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oxlint: ~500K weekly downloads&lt;/strong&gt; — 50-100x faster than ESLint, linting only, ~300 rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Biome v2 ships type-aware linting&lt;/strong&gt; — previously only possible with @typescript-eslint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Biome lints 10,000 files in 0.8 seconds&lt;/strong&gt; vs ESLint's ~45 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oxlint is ~2x faster than Biome&lt;/strong&gt; for pure linting workloads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ESLint flat config&lt;/strong&gt; (eslint.config.js) is now the standard since v9 — old cascading config deprecated&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Rust Tooling Revolution
&lt;/h2&gt;

&lt;p&gt;JavaScript tooling has entered its Rust era. Both Biome and Oxlint are written in Rust and compiled to native binaries — that's the source of their 10-100x speed advantage over Node.js-based ESLint. The performance gap isn't about algorithms; it's about the runtime. Node.js has JIT compilation, garbage collection pauses, and startup overhead that native Rust binaries simply don't have. When parsing and traversing abstract syntax trees for millions of lines of code, these differences compound.&lt;/p&gt;

&lt;p&gt;The practical impact: a mid-sized repo that took ESLint 30-45 seconds to lint now takes Biome under 1 second or Oxlint under 0.5 seconds. In CI, this translates directly to developer cost and iteration speed. At $0.008 per CI minute on GitHub Actions, a team running 50 CI jobs per day that each spend 30 seconds on linting pays roughly $438 per year just for ESLint. Biome brings that to under $30. The math isn't hypothetical — multiple engineering teams have published case studies showing 60-80% CI cost reduction from switching linters.&lt;/p&gt;

&lt;p&gt;But speed alone doesn't determine the right choice. Linting is fundamentally about catching bugs and enforcing code standards. A fast linter that doesn't catch your bugs is worthless. The question is whether Biome or Oxlint's rule coverage is sufficient for your codebase — and for many teams in 2026, it increasingly is.&lt;/p&gt;

&lt;p&gt;For context on the broader tooling landscape, see &lt;a href="https://dev.to/blog/best-code-formatting-tools-2026"&gt;Best Code Formatting Tools 2026&lt;/a&gt; and &lt;a href="https://dev.to/blog/oxc-vs-eslint-vs-biome-javascript-linting-2026"&gt;OXC vs ESLint vs Biome: JavaScript Linting in 2026&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;ESLint v9&lt;/th&gt;
&lt;th&gt;Biome v2&lt;/th&gt;
&lt;th&gt;Oxlint 0.x&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly Downloads&lt;/td&gt;
&lt;td&gt;50M+&lt;/td&gt;
&lt;td&gt;~1.5M&lt;/td&gt;
&lt;td&gt;~500K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Written In&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linting&lt;/td&gt;
&lt;td&gt;Yes (700+ rules)&lt;/td&gt;
&lt;td&gt;Yes (423+ rules)&lt;/td&gt;
&lt;td&gt;Yes (~300 rules)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formatting&lt;/td&gt;
&lt;td&gt;No (needs Prettier)&lt;/td&gt;
&lt;td&gt;Yes (built-in)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-fix&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type-aware rules&lt;/td&gt;
&lt;td&gt;Yes (@typescript-eslint)&lt;/td&gt;
&lt;td&gt;Yes (v2+)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin ecosystem&lt;/td&gt;
&lt;td&gt;4000+ packages&lt;/td&gt;
&lt;td&gt;Growing&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config complexity&lt;/td&gt;
&lt;td&gt;Medium (flat config)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed vs ESLint&lt;/td&gt;
&lt;td&gt;1x&lt;/td&gt;
&lt;td&gt;10-20x&lt;/td&gt;
&lt;td&gt;50-100x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  ESLint v9
&lt;/h2&gt;

&lt;p&gt;ESLint turned 13 in 2026 and is more dominant than ever. The v9 release brought &lt;strong&gt;flat config&lt;/strong&gt; — a single &lt;code&gt;eslint.config.js&lt;/code&gt; replacing the old cascading &lt;code&gt;.eslintrc.*&lt;/code&gt; system — which significantly reduces configuration surprises in monorepos.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.js (flat config, ESLint v9)&lt;/span&gt;

  &lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.{ts,tsx}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;parserOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./tsconfig.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@typescript-eslint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsPlugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@typescript-eslint/no-explicit-any&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@typescript-eslint/strict-boolean-expressions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;**/*.{jsx,tsx}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;react&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reactPlugin&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;reactPlugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react/react-in-jsx-scope&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ESLint's irreplaceable advantage is its &lt;strong&gt;plugin ecosystem&lt;/strong&gt;. Framework-specific rules (Next.js, Remix, Astro), accessibility (jsx-a11y), security (eslint-plugin-security), and hundreds of specialized plugins have no equivalent in Biome or Oxlint. If you use Storybook, Vitest, or Playwright, there are ESLint plugins that know your APIs. The eslint-plugin-react-hooks rules alone have caught thousands of real bugs in production codebases — Biome ships equivalents, but the React hooks plugin's 12+ years of battle-testing means it handles subtle edge cases that newer implementations may miss.&lt;/p&gt;

&lt;p&gt;The v9 flat config migration is worth understanding before committing to ESLint. The old cascading &lt;code&gt;.eslintrc.json&lt;/code&gt; system created subtle inheritance bugs in monorepos where config files at different directory levels would combine in unexpected ways. Flat config is explicit: one &lt;code&gt;eslint.config.js&lt;/code&gt; at the workspace root, no implicit inheritance, no surprise rule combinations. Teams that have migrated report significantly fewer "why is this rule triggering here?" debugging sessions.&lt;/p&gt;

&lt;p&gt;The performance story is improving too. With Vercel's adoption of Oxlint as a pre-pass linter alongside ESLint, teams are finding 60%+ CI improvements without abandoning ESLint's rule depth. The dual-linter pattern (Oxlint fast pass + ESLint for specialized rules) is gaining traction specifically because it preserves ESLint's plugin ecosystem while addressing its performance bottleneck.&lt;/p&gt;

&lt;p&gt;ESLint also has the most mature TypeScript integration. &lt;code&gt;@typescript-eslint&lt;/code&gt; provides rules that understand your TypeScript types — not just syntax. Rules like &lt;code&gt;no-floating-promises&lt;/code&gt;, &lt;code&gt;strict-boolean-expressions&lt;/code&gt;, and &lt;code&gt;consistent-return&lt;/code&gt; catch real TypeScript bugs that Biome is only beginning to approach with its v2 type-aware rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When ESLint is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any project with specialized plugin requirements (Next.js, jsx-a11y, testing frameworks)&lt;/li&gt;
&lt;li&gt;Teams with existing ESLint configs where migration cost outweighs speed gains&lt;/li&gt;
&lt;li&gt;Monorepos with mixed framework targets needing different rule sets per project&lt;/li&gt;
&lt;li&gt;When @typescript-eslint's type-aware rules are required for safety-critical code&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Biome
&lt;/h2&gt;

&lt;p&gt;Biome emerged from the Rome project and has become the most credible ESLint+Prettier replacement for new projects. The v2 release was significant: it added type-aware linting rules (previously exclusive to @typescript-eslint), bringing Biome's capabilities much closer to a full ESLint+typescript-eslint setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;biome.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://biomejs.dev/schemas/2.0.0/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"organizeImports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"linter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"recommended"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"suspicious"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"noExplicitAny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"style"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"useConst"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"formatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"indentStyle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"space"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"indentWidth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lineWidth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"javascript"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"formatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"quoteStyle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"double"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"trailingCommas"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"es5"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Lint + format in one command&lt;/span&gt;
npx @biomejs/biome check &lt;span class="nt"&gt;--write&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# CI (no writes, exit code 1 on violations)&lt;/span&gt;
npx @biomejs/biome ci &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Biome's formatter is a drop-in Prettier replacement for most codebases. It formats JavaScript, TypeScript, JSX, JSON, and CSS. The output is intentionally close to Prettier's — migration scripts are available. Where Prettier and Biome differ, it's usually in edge cases involving complex expressions or unusual syntax — for typical TypeScript React codebases, the output is practically identical.&lt;/p&gt;

&lt;p&gt;The v2 addition of type-aware linting is Biome's most significant capability expansion. Previously, rules that needed TypeScript type information — like "this function might return undefined but the caller doesn't check" — required &lt;code&gt;@typescript-eslint&lt;/code&gt; with a type-checked config, which adds significant parse time because TypeScript's compiler needs to run. Biome v2 achieves similar analysis through its own type inference engine, maintaining its speed advantage while matching more of ESLint's rule coverage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Biome understands this TypeScript correctly&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processItems&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One practical consideration: Biome's VS Code extension is mature and provides real-time feedback with the same speed advantages as the CLI. Format-on-save with Biome is noticeably faster than with Prettier, which matters for large files or complex TypeScript. Teams that switch often mention the editor experience as an unexpected quality-of-life improvement.&lt;/p&gt;

&lt;p&gt;The biggest practical migration challenge from ESLint to Biome is the missing rules. Biome's 423+ rules are growing rapidly, but specialized plugins — eslint-plugin-import, eslint-plugin-testing-library, Storybook-specific rules — either don't exist in Biome or are partial. Audit your current ESLint config before migrating: if you rely on 5+ specialized plugins, Biome may not yet cover enough of your rule set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Biome is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New projects where you want zero Prettier + ESLint config overhead&lt;/li&gt;
&lt;li&gt;Teams that prioritize fast CI and local feedback loops&lt;/li&gt;
&lt;li&gt;Projects not requiring more than 5-6 specialized ESLint plugins&lt;/li&gt;
&lt;li&gt;Codebases wanting both linting and formatting in one consistent tool&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Oxlint
&lt;/h2&gt;

&lt;p&gt;Oxlint (part of the OXC project) focuses on raw linting speed above all else. At 50-100x faster than ESLint, it can lint a monorepo with 100K lines in under a second. The tradeoff: no formatting, limited auto-fix, ~300 rules, and virtually no plugin ecosystem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install and run&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; oxlint
npx oxlint src/

&lt;span class="c"&gt;# With specific rules&lt;/span&gt;
npx oxlint &lt;span class="nt"&gt;--deny&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;correctness &lt;span class="nt"&gt;--warn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;perf src/

&lt;span class="c"&gt;# TypeScript files&lt;/span&gt;
npx oxlint &lt;span class="nt"&gt;--tsconfig&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tsconfig.json src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.oxlintrc.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"no-unused-vars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"no-console"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"react/no-direct-mutation-state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"typescript"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignorePatterns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dist/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node_modules/"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most pragmatic Oxlint use case in 2026 is the &lt;strong&gt;dual-linter approach&lt;/strong&gt;: Oxlint as a fast pre-pass for obvious errors (runs in 0.3s), then ESLint for type-aware and framework-specific rules (runs in 30s but only on changed files). Vercel's engineering blog documented 60%+ CI time reduction using exactly this pattern. The idea is that most trivial errors — unused variables, wrong equality operators, deprecated APIs — can be caught by Oxlint instantly before ESLint even starts. ESLint then only runs on files that passed Oxlint, and only when the Oxlint pass is clean.&lt;/p&gt;

&lt;p&gt;Oxlint's auto-fix capabilities are a known limitation. Where ESLint and Biome can auto-fix the majority of their reported issues, Oxlint's auto-fix coverage is selective. Teams using Oxlint as their primary linter need to be comfortable manually addressing some categories of reported issues. The OXC team is actively expanding auto-fix support, but as of early 2026 it's still behind ESLint and Biome in this area.&lt;/p&gt;

&lt;p&gt;Another consideration is Oxlint's configuration system. It's simpler than ESLint's flat config, which is either a feature or a limitation depending on your needs. You can't yet define complex per-directory rule overrides or build a layered config system comparable to ESLint. For simple projects this is fine; for monorepos with different standards across packages, it's a gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Oxlint is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CI pre-pass alongside ESLint for large monorepos where linting is a bottleneck&lt;/li&gt;
&lt;li&gt;Basic linting for simple Node.js scripts or CLIs without framework requirements&lt;/li&gt;
&lt;li&gt;Teams gradually migrating toward Rust-based tooling who want to start simply&lt;/li&gt;
&lt;li&gt;Projects where zero-config basic correctness checks are the primary need&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose ESLint v9&lt;/strong&gt; as your default unless you have a compelling reason to switch. Its flat config is cleaner than the old system, and no other tool matches its plugin depth. The performance cost is real but often acceptable — especially with caching enabled via &lt;code&gt;--cache&lt;/code&gt; and running only on changed files in CI. ESLint is also the lowest-risk choice: every tutorial, every framework guide, and every team you hire from will know it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Biome&lt;/strong&gt; for new projects in 2026 — it's the most mature ESLint+Prettier alternative and v2's type-aware rules close the functionality gap significantly. If your project doesn't need specialized ESLint plugins, Biome is strictly better: faster, simpler, one tool. The developer experience of a single &lt;code&gt;biome check --write&lt;/code&gt; for both linting and formatting is genuinely pleasant compared to coordinating ESLint and Prettier separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Oxlint&lt;/strong&gt; as a CI accelerator alongside ESLint, not as a replacement. If your linting is a bottleneck and you can't migrate to Biome, Oxlint's dual-linter pattern offers significant speedups with minimal migration cost. Think of Oxlint as a fast pre-screen, not a full linting solution.&lt;/p&gt;

&lt;p&gt;The trajectory matters here: Biome is moving fast. Rules coverage is growing, type-aware linting is new, and the community is maturing. A project that couldn't migrate to Biome six months ago because of a missing plugin might be able to today. It's worth rechecking Biome's rule parity with your current ESLint config every quarter if you're on ESLint and considering a switch.&lt;/p&gt;

&lt;p&gt;For migration guidance, see &lt;a href="https://dev.to/blog/how-to-migrate-eslint-to-biome"&gt;How to Migrate ESLint to Biome&lt;/a&gt; and &lt;a href="https://dev.to/blog/eslint-vs-biome-2026"&gt;ESLint vs Biome 2026&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;Performance data sourced from the OXC benchmark repository (oxc-project/bench-javascript-linter) and Biome documentation. Download statistics from npm trends (March 2026). Rule counts from official documentation for Biome v2 and Oxlint 0.x. The 10,000-file benchmark reflects a mid-sized TypeScript monorepo with mixed JSX and utility code.&lt;/p&gt;

</description>
      <category>biome</category>
      <category>eslint</category>
      <category>oxlint</category>
      <category>linting</category>
    </item>
    <item>
      <title>Zod vs Yup vs Valibot: Schema Validation 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:08:19 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/zod-vs-yup-vs-valibot-schema-validation-2026-2j00</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/zod-vs-yup-vs-valibot-schema-validation-2026-2j00</guid>
      <description>&lt;h1&gt;
  
  
  Zod vs Yup vs Valibot: Schema Validation 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Zod is the default choice for TypeScript projects in 2026 — 31M weekly downloads, excellent DX, and Zod v4 closed most of the performance gap.&lt;/strong&gt; Valibot wins on bundle size (1.4 KB vs Zod's 15+ KB) and is the right pick for edge/bundle-sensitive apps. Yup remains relevant for Formik codebases but has lost significant ground to both competitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zod: 31M weekly downloads, 38.5K GitHub stars&lt;/strong&gt; — the clear ecosystem leader&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yup: 5.1M weekly downloads, 23.6K GitHub stars&lt;/strong&gt; — strong but declining market share&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valibot: 1.4M weekly downloads, 7.7K GitHub stars&lt;/strong&gt; — fastest growing, bundle-first design&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod v4 is 14x faster&lt;/strong&gt; at string parsing, 7x faster at arrays vs Zod v3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valibot's bundle footprint is ~1.4 KB&lt;/strong&gt; for a login form vs Zod v4's 15 KB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All three support Standard Schema&lt;/strong&gt; — framework-level interoperability is now guaranteed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yup has no TypeScript-first design&lt;/strong&gt; — type inference is bolted on, not native&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Schema Validation Matters in 2026
&lt;/h2&gt;

&lt;p&gt;Runtime schema validation is no longer optional. With tRPC, server actions, and form libraries all converging on schema-driven APIs, your choice of validation library now affects your entire stack — bundle size, TypeScript inference quality, and integration with tools like React Hook Form, Hono, and Valibot.&lt;/p&gt;

&lt;p&gt;The landscape shifted dramatically in 2025 with two events: Zod v4's release (massive performance leap) and the Standard Schema specification reaching v1.0. Standard Schema means Zod, Valibot, and ArkType can now be used interchangeably in compatible frameworks — your schema library is less locked-in than ever.&lt;/p&gt;

&lt;p&gt;Before Standard Schema, choosing a validation library was a sticky decision. If you picked Zod for a React Hook Form project, switching later meant rewriting every schema. If a library added Zod support but not Valibot support, you were locked in by your initial choice. Standard Schema changes this by defining a common interface that framework authors can target once and get compatibility with all compliant libraries. React Hook Form, Hono's validator middleware, and several others have adopted Standard Schema, which means the ecosystem pressure that used to favor only Zod now distributes across compliant libraries.&lt;/p&gt;

&lt;p&gt;The practical stakes of schema validation extend beyond form inputs. API route handlers validate request bodies. tRPC procedures declare input schemas that generate both TypeScript types and runtime checks. Drizzle's insert operations can be validated against derived schemas. Server actions in Next.js and Remix parse FormData through schema declarations. Every one of these integration points is affected by your choice of schema library — which is why 2026 is a genuinely interesting time to compare the field.&lt;/p&gt;

&lt;p&gt;For a deeper look at the form validation ecosystem, see &lt;a href="https://dev.to/blog/best-react-form-libraries-2026"&gt;Best React Form Libraries 2026&lt;/a&gt; and &lt;a href="https://dev.to/blog/zod-v4-vs-arktype-vs-typebox-vs-valibot-schema-validation-2026"&gt;Zod v4 vs ArkType vs TypeBox vs Valibot&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Zod v4&lt;/th&gt;
&lt;th&gt;Yup 1.x&lt;/th&gt;
&lt;th&gt;Valibot 1.x&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly Downloads&lt;/td&gt;
&lt;td&gt;31M&lt;/td&gt;
&lt;td&gt;5.1M&lt;/td&gt;
&lt;td&gt;1.4M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;38.5K&lt;/td&gt;
&lt;td&gt;23.6K&lt;/td&gt;
&lt;td&gt;7.7K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle Size (login form)&lt;/td&gt;
&lt;td&gt;~15 KB&lt;/td&gt;
&lt;td&gt;~24 KB&lt;/td&gt;
&lt;td&gt;~1.4 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript-first&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard Schema&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Async validation&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tree-shakable&lt;/td&gt;
&lt;td&gt;Partial (Mini)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inference quality&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Zod v4
&lt;/h2&gt;

&lt;p&gt;Zod, created by Colin McDonnell, has been the TypeScript validation standard since 2020. The v4 release in 2025 was a landmark: 14x faster string parsing, 7x faster array parsing, 6.5x faster object parsing compared to v3. Zod v4 also introduced &lt;strong&gt;Zod Mini&lt;/strong&gt; — a tree-shakable, functional variant that brings bundle size down from ~68 KB (v3 CommonJS) to around 15 KB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Parse (throws on failure)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Safe parse (returns result object)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawInput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Zod Mini&lt;/strong&gt; uses the functional pipe style for better tree-shaking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EmailSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EmailSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "user@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zod's biggest strength is its ecosystem depth. React Hook Form, tRPC, Drizzle, Hono, Next.js server actions — virtually every major TypeScript tool has first-class Zod support. When you reach for a new library and ask "does it have a Zod adapter?", the answer is almost always yes. This network effect compounds over time: the more tools support Zod natively, the more friction there is in switching away, and the more new tools prioritize Zod support first.&lt;/p&gt;

&lt;p&gt;Zod v4's performance improvements are substantial and worth understanding in detail. The team rewrote the core parsing engine and achieved 14x faster string parsing, 7x faster array parsing, and 6.5x faster object parsing compared to v3. For most applications, raw parsing performance at the library level isn't the bottleneck — network latency and database queries dominate. But for high-throughput API servers processing thousands of requests per second, these improvements translate directly to server capacity. The v4 improvements also matter for the cold start cost in edge functions, where parsing overhead is more visible.&lt;/p&gt;

&lt;p&gt;Error formatting is another area where Zod excels. The &lt;code&gt;flatten()&lt;/code&gt; method produces nested error objects that map directly to form field paths, making it trivial to display field-level validation errors in a UI. The &lt;code&gt;.format()&lt;/code&gt; method gives you the full error tree with custom message support. This level of ergonomic error handling is one of the reasons Zod became the default for form-adjacent validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Zod is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;General TypeScript projects where ecosystem compatibility matters&lt;/li&gt;
&lt;li&gt;tRPC or server actions with complex schemas&lt;/li&gt;
&lt;li&gt;Team familiarity is a priority&lt;/li&gt;
&lt;li&gt;You need rich error formatting out of the box&lt;/li&gt;
&lt;li&gt;Existing codebase with Zod already present&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Yup
&lt;/h2&gt;

&lt;p&gt;Yup was the original JavaScript schema validation library and reached dominance through its Formik integration. Its fluent, chainable API was intuitive for JavaScript developers who weren't yet thinking TypeScript-first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mixed&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;oneOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InferType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yup's asynchronous validation is genuinely useful — you can validate against a database or API within your schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UsernameSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;yup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unique-username&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Username already taken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkUsernameExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where Yup struggles in 2026:&lt;/strong&gt; TypeScript inference is retrofitted rather than native. &lt;code&gt;InferType&lt;/code&gt; works but edge cases (discriminated unions, recursive schemas) produce less precise types than Zod. Yup also has no Standard Schema support, meaning newer frameworks won't add first-class Yup adapters going forward. The library has essentially reached feature stability — active maintenance continues, but the project isn't innovating at the pace of Zod or Valibot.&lt;/p&gt;

&lt;p&gt;Yup's bundle size is also its largest practical disadvantage. At ~24 KB for a simple login form, it's the heaviest of the three. Unlike Zod Mini or Valibot, Yup offers no tree-shakable variant. Every validator, whether you use it or not, ships in the bundle. For server-side validation this doesn't matter, but for client-side or edge validation it's a meaningful cost.&lt;/p&gt;

&lt;p&gt;That said, Yup's async-first design is genuinely useful in certain contexts. When validation logic needs to consult external state — checking username uniqueness against a database, verifying an email isn't blocklisted, confirming a promo code is valid — Yup's &lt;code&gt;.test()&lt;/code&gt; method makes this clean and composable in a way that feels natural to JavaScript developers. Zod supports async refinements too, but Yup's API for it feels more intuitive to developers who grew up with promise-based JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Yup is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing Formik codebases already using Yup&lt;/li&gt;
&lt;li&gt;Teams with strong JavaScript (not TypeScript) background&lt;/li&gt;
&lt;li&gt;Complex async validations are the dominant use case&lt;/li&gt;
&lt;li&gt;Migrating incrementally from Joi with minimal disruption&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Valibot
&lt;/h2&gt;

&lt;p&gt;Valibot takes a fundamentally different architectural approach: every validator is a pure function, and schemas are composed via a functional &lt;code&gt;pipe()&lt;/code&gt; API. This design enables aggressive tree-shaking — unused validators simply aren't bundled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;picklist&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InferOutput&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Safe parse&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawInput&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bundle size in practice:&lt;/strong&gt; A login form schema (email + password) compiles to ~1.4 KB with Valibot vs ~15 KB with Zod v4 and ~24 KB with Yup. For edge functions, Cloudflare Workers, or mobile web apps, this difference is material. Cloudflare Workers has a 1 MB compressed bundle limit. If your worker handles form validation, Valibot takes 1.4 KB of that budget vs Zod's 15 KB. For a simple worker this doesn't matter, but for workers that also bundle a route framework, database client, and business logic, every kilobyte counts. Valibot's architecture was specifically designed with this constraint in mind — the library author originally built it while working on an edge-deployed application and found Zod's bundle cost unacceptable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Valibot v1 — Standard Schema compatible&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LoginSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Generate JSON Schema for OpenAPI&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jsonSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toJsonSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LoginSchema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Valibot's runtime performance is roughly 2x faster than Zod v3 and comparable to Zod v4. The modular API requires slightly more verbose code for complex schemas, which is the primary DX tradeoff. Where Zod lets you write &lt;code&gt;z.string().email().min(5)&lt;/code&gt; as a method chain, Valibot requires &lt;code&gt;v.pipe(v.string(), v.email(), v.minLength(5))&lt;/code&gt;. This is more characters but also more explicit — each validator is a discrete, nameable function, which is better for static analysis and tree-shaking.&lt;/p&gt;

&lt;p&gt;The Valibot ecosystem has grown substantially in 2025-2026. React Hook Form's Valibot resolver is production-ready. Hono's validator middleware supports Valibot natively. The &lt;code&gt;@valibot/to-json-schema&lt;/code&gt; package generates OpenAPI-compatible JSON Schema from Valibot schemas, which matters for tightly-coupled API documentation. Standard Schema compliance means any library that added Standard Schema support automatically works with Valibot without additional adapter code.&lt;/p&gt;

&lt;p&gt;One important consideration: Valibot's error messages are less detailed by default than Zod's. Zod generates rich, human-readable error messages with path information and issue codes out of the box. Valibot's issues are more raw and require manual formatting to display nicely in a UI. This is a deliberate tradeoff — better tree-shaking requires smaller default outputs — but it means more work for form validation UI code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Valibot is the right choice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edge functions, Cloudflare Workers, or bundle-size-critical apps&lt;/li&gt;
&lt;li&gt;New projects that want maximum tree-shaking from day one&lt;/li&gt;
&lt;li&gt;You want Standard Schema compliance without Zod's weight&lt;/li&gt;
&lt;li&gt;React Native or embedded web contexts where bundle size is measured in KB&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Zod v4&lt;/strong&gt; when you need the richest ecosystem, best-in-class DX, and are building general TypeScript apps. The gap with Valibot on performance and bundle size is now smaller than ever, and the ecosystem advantage is enormous. Zod is particularly strong for tRPC projects — the library was designed with tRPC in mind, and the integration is seamless in both directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Yup&lt;/strong&gt; only if you're maintaining an existing Formik codebase or migrating from Joi. For new projects, Zod or Valibot are better starting points. The maintenance burden of keeping Yup in a new project when better alternatives exist isn't justified by any significant advantage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Valibot&lt;/strong&gt; when bundle size directly impacts user experience — edge functions, mobile-optimized web apps, or any context where 15 KB matters. Valibot's growing ecosystem (React Hook Form resolver, Hono validator, Standard Schema support) means you won't sacrifice compatibility for the bundle savings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on migration cost:&lt;/strong&gt; All three libraries have similar API surface areas for simple schemas. Migration from Yup to Zod is well-documented and usually straightforward. Migration from Zod to Valibot requires rewriting schemas (different API), but codegens and scripts are available. If you're on Zod and bundle size becomes a concern, consider Zod Mini before switching to Valibot — the functional API is closer to Valibot's style and might be sufficient.&lt;/p&gt;

&lt;p&gt;For related comparison, see &lt;a href="https://dev.to/blog/valibot-vs-zod-v4-typescript-validator-2026"&gt;Valibot vs Zod v4&lt;/a&gt; and &lt;a href="https://dev.to/blog/joi-vs-zod-2026"&gt;Joi vs Zod 2026&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;Data sourced from npm trends (March 2026), GitHub repository pages, and official benchmarks from the Valibot documentation and Zod v4 release notes. Bundle sizes measured with esbuild for a login form schema (email + password fields). Download counts represent weekly averages from the 30 days prior to publication.&lt;/p&gt;

</description>
      <category>zod</category>
      <category>yup</category>
      <category>valibot</category>
      <category>schemavalidation</category>
    </item>
    <item>
      <title>Convex vs Supabase vs Firebase for SaaS Starters 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:07:09 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/convex-vs-supabase-vs-firebase-for-saas-starters-2026-jnb</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/convex-vs-supabase-vs-firebase-for-saas-starters-2026-jnb</guid>
      <description>&lt;p&gt;Choosing a real-time backend for a SaaS starter is one of the earliest and hardest-to-reverse decisions you make. Convex, Supabase, and Firebase each solve the same problem — give your application a backend with auth, database, and real-time data — but their architectures, pricing models, and developer experiences are fundamentally different. This guide breaks down all three as of 2026 so you can pick the right backend for your SaaS starter without learning the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Philosophies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Convex&lt;/strong&gt; is a TypeScript-native reactive backend. You write server functions in TypeScript, and any data change automatically propagates to all subscribed clients in real time. There is no SQL. There is no REST layer to define. Your database schema is your TypeScript types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase&lt;/strong&gt; is PostgreSQL with a developer experience layer. It gives you a managed Postgres database, Row Level Security for access control, a realtime engine that listens to Postgres WAL changes, built-in auth, and an S3-compatible storage layer. Everything is built on proven open-source infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firebase&lt;/strong&gt; is Google's mature backend-as-a-service platform. Firestore (the document database) and the Realtime Database are the core offerings, with Firebase Auth, Cloud Storage, and Cloud Functions completing the platform. Firebase is the most established of the three, with the broadest mobile SDK support and the deepest Google ecosystem integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Convex Architecture
&lt;/h3&gt;

&lt;p&gt;Convex runs your backend logic in a serverless TypeScript runtime. You write "mutations" (writes), "queries" (reads), and "actions" (side effects that can call external APIs). Queries are reactive by default — when the underlying data changes, every component subscribed to that query receives the update automatically without any manual subscription management.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/messages.ts&lt;/span&gt;

  &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;author&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;author&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the client, you use &lt;code&gt;useQuery&lt;/code&gt; and it stays live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// messages updates automatically when data changes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Convex reactive model eliminates the need to manually manage websocket connections, invalidate caches, or write polling logic. It is the most developer-friendly real-time experience of the three options.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase Architecture
&lt;/h3&gt;

&lt;p&gt;Supabase wraps PostgreSQL with a REST API (PostgREST), a GraphQL API, and a realtime engine built on Elixir and Phoenix Channels. The realtime layer listens to the Postgres Write-Ahead Log (WAL) and broadcasts changes to subscribed clients.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Supabase realtime subscription&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anonKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres_changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Supabase realtime model gives you change events from the database. You receive notifications of what changed and update your UI accordingly. It is less automatic than Convex (you manage the local state update) but it is battle-tested at scale and works with the full power of PostgreSQL — joins, indexes, full-text search, and complex queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firebase Architecture
&lt;/h3&gt;

&lt;p&gt;Firebase Firestore uses real-time listeners that fire whenever the underlying NoSQL document changes. The SDK abstracts away websocket management:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;createdAt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firebase's realtime is built on Google's proprietary WebSocket infrastructure and is famously fast for document-level changes. Offline support for mobile apps is best-in-class — Firebase caches data locally and syncs when connectivity is restored, which is why it remains dominant in mobile SaaS applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realtime Performance
&lt;/h2&gt;

&lt;p&gt;Benchmarks from independent tests in 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Convex&lt;/th&gt;
&lt;th&gt;Supabase&lt;/th&gt;
&lt;th&gt;Firebase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;P50 update latency&lt;/td&gt;
&lt;td&gt;~20ms&lt;/td&gt;
&lt;td&gt;~50ms&lt;/td&gt;
&lt;td&gt;~30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P99 latency (5,000 concurrent)&lt;/td&gt;
&lt;td&gt;&amp;lt;50ms&lt;/td&gt;
&lt;td&gt;100–200ms&lt;/td&gt;
&lt;td&gt;&amp;lt;100ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline support&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (partial)&lt;/td&gt;
&lt;td&gt;Yes (Firestore)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reactive queries&lt;/td&gt;
&lt;td&gt;Yes (automatic)&lt;/td&gt;
&lt;td&gt;Manual subscription&lt;/td&gt;
&lt;td&gt;Manual onSnapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Convex sustains sub-50ms read/write latency at 5,000 concurrent connections. Supabase can see 100–200ms P99 latencies under similar load because the WAL-based realtime engine has higher processing overhead than Convex's purpose-built reactive system. Firebase Realtime Database (not Firestore) is the fastest for simple key-value subscriptions but is limited in query capability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing Comparison
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Convex
&lt;/h3&gt;

&lt;p&gt;The Convex free plan supports up to 6 developers working on 20 projects with 1 million function calls per month, 20 GB-hours of compute, and basic database and file storage. Convex Pro is priced per seat with additional charges for function execution overage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost model:&lt;/strong&gt; Compute-based (function execution time and calls). Costs scale with app usage, not data volume.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase
&lt;/h3&gt;

&lt;p&gt;The Supabase free tier gives you 2 projects with 500 MB database, 5 GB bandwidth, and 50,000 monthly active users. Supabase Pro is $25/month per project with 8 GB database, 250 GB bandwidth, and 100,000 MAU. Team plan is $599/month for SOC2 compliance and priority support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost model:&lt;/strong&gt; Service usage (database size, bandwidth, MAU). Most production apps land at $35–75/month after factoring in overages. At 500,000 MAU, auth costs alone reach $1,300/month.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firebase
&lt;/h3&gt;

&lt;p&gt;Firebase (Google Cloud) uses a pay-as-you-go model for Firestore: $0.06 per 100,000 reads, $0.18 per 100,000 writes, $0.02 per 100,000 deletes, plus $0.18 per GB stored. The Spark free plan is generous for development. Costs at scale can be unpredictable — high-read applications can generate large bills because every Firestore query counts individual document reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost model:&lt;/strong&gt; Operations-based (reads/writes/deletes). Firebase is the most expensive at scale for read-heavy applications. Supabase Pro at $25/month is 4–5x cheaper for equivalent usage in most SaaS workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Developer Experience Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Convex&lt;/th&gt;
&lt;th&gt;Supabase&lt;/th&gt;
&lt;th&gt;Firebase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema definition&lt;/td&gt;
&lt;td&gt;TypeScript types&lt;/td&gt;
&lt;td&gt;SQL + Drizzle/Prisma&lt;/td&gt;
&lt;td&gt;NoSQL (schemaless)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query language&lt;/td&gt;
&lt;td&gt;TypeScript API&lt;/td&gt;
&lt;td&gt;SQL&lt;/td&gt;
&lt;td&gt;Firestore Query API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local dev&lt;/td&gt;
&lt;td&gt;Convex dev server&lt;/td&gt;
&lt;td&gt;supabase CLI&lt;/td&gt;
&lt;td&gt;Firebase emulator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type safety&lt;/td&gt;
&lt;td&gt;End-to-end&lt;/td&gt;
&lt;td&gt;Partial (via ORM)&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosting&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Convex's TypeScript-native approach gives the tightest end-to-end type safety. Your database schema, server functions, and client queries share types without a code generation step. Supabase achieves similar type safety when you use Drizzle ORM or Prisma on top of the PostgreSQL layer — but this requires more setup. Firebase's Firestore is schemaless, which trades type safety for flexibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth Comparison
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Convex Auth&lt;/strong&gt; (convex-auth package): Provides email/password and OAuth flows built on top of Convex's data model. Relatively new compared to Supabase or Firebase Auth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase Auth&lt;/strong&gt;: Full-featured authentication with email/password, magic links, OAuth (50+ providers), phone OTP, and SAML/SSO for enterprise. Row Level Security integrates directly with Supabase Auth, so your database access policies are co-located with your schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firebase Auth&lt;/strong&gt;: The most mature mobile auth SDK available. Social login, phone auth, anonymous auth, and deep integration with Firebase security rules. If you are building a mobile app with React Native or Flutter, Firebase Auth has the best native SDK support.&lt;/p&gt;

&lt;h2&gt;
  
  
  SaaS Boilerplate Ecosystem
&lt;/h2&gt;

&lt;p&gt;All three backends have SaaS boilerplate support, but the ecosystems differ significantly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase&lt;/strong&gt; has the largest boilerplate ecosystem. Nearly every major Next.js SaaS starter supports Supabase. See our &lt;a href="https://dev.to/blog/best-saas-boilerplates-with-supabase-2026"&gt;best SaaS boilerplates with Supabase guide&lt;/a&gt; for the full list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Convex&lt;/strong&gt; has a growing set of dedicated starters. See the &lt;a href="https://dev.to/blog/best-convex-boilerplates-saas-2026"&gt;best Convex boilerplates guide&lt;/a&gt; for Convex-specific options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firebase&lt;/strong&gt; has mature support in older boilerplates and strong React Native/Flutter starter coverage, but is less common in the latest Next.js SaaS starters — most have migrated to Supabase.&lt;/p&gt;

&lt;p&gt;For a comparison that includes PocketBase as a self-hosted alternative, see our &lt;a href="https://dev.to/blog/convex-vs-supabase-vs-pocketbase-saas-starters-2026"&gt;Convex vs Supabase vs PocketBase comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Choose Convex When
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You are building a highly interactive, real-time SaaS product (collaborative tools, live dashboards, multiplayer features)&lt;/li&gt;
&lt;li&gt;Your team is TypeScript-only and wants end-to-end type safety with zero SQL&lt;/li&gt;
&lt;li&gt;You want the simplest possible reactive data model without managing websocket subscriptions&lt;/li&gt;
&lt;li&gt;You are building a product where real-time UX is a core differentiator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Avoid Convex if:&lt;/strong&gt; You need self-hosting, SQL-based analytics, complex relational joins, or an open-source backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choose Supabase When
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Your SaaS is data-intensive with complex relational queries&lt;/li&gt;
&lt;li&gt;You need PostgreSQL's power (full-text search, PostGIS, pg_vector for AI features)&lt;/li&gt;
&lt;li&gt;Open source and self-hostability are requirements&lt;/li&gt;
&lt;li&gt;You want Row Level Security to enforce access control at the database level&lt;/li&gt;
&lt;li&gt;Your team is SQL-familiar or uses an ORM like Prisma or Drizzle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Avoid Supabase if:&lt;/strong&gt; Your primary use case is simple document storage with heavy real-time updates at scale, or if your team has no SQL experience.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choose Firebase When
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You are building a mobile-first SaaS (React Native, Flutter, native iOS/Android)&lt;/li&gt;
&lt;li&gt;Offline support is a product requirement&lt;/li&gt;
&lt;li&gt;You are already in the Google Cloud ecosystem&lt;/li&gt;
&lt;li&gt;You need the most mature and battle-tested mobile auth SDK available&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Avoid Firebase if:&lt;/strong&gt; You are building a web-only SaaS, you are cost-sensitive at scale, or you want to avoid vendor lock-in (Firebase is proprietary and cannot be self-hosted).&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Complexity
&lt;/h2&gt;

&lt;p&gt;If you choose wrong and need to migrate:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase → Firebase or Convex:&lt;/strong&gt; Supabase exports PostgreSQL dumps. Migrating to Convex requires rewriting queries to the Convex API. Migrating to Firebase requires transforming relational data to a document model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firebase → Supabase:&lt;/strong&gt; The most common migration path in 2026 as teams move from Firebase's NoSQL to PostgreSQL for better query capability. Firebase exports JSON; Supabase provides migration tools for common document-to-relational patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Convex → anything:&lt;/strong&gt; Convex exports data as JSON. No SQL schema to migrate, but you lose the reactive query model and need to rebuild subscriptions.&lt;/p&gt;

&lt;p&gt;The safest long-term choice for migration flexibility is Supabase, because PostgreSQL is portable and the export format is standard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Recommendation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For most SaaS starters in 2026: Supabase.&lt;/strong&gt; The combination of PostgreSQL reliability, open-source freedom, mature auth, and the largest boilerplate ecosystem makes it the default choice. It is also the most cost-predictable at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For real-time-first products: Convex.&lt;/strong&gt; If your SaaS is built around live collaboration, reactive dashboards, or multiplayer features, Convex's reactive query model is worth the tradeoff of no SQL and no self-hosting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For mobile-first products: Firebase.&lt;/strong&gt; If most of your users are on mobile and offline support matters, Firebase Auth and Firestore are still the strongest mobile backend combination available.&lt;/p&gt;

&lt;p&gt;Start with Supabase unless your use case clearly maps to Convex or Firebase. The boilerplate ecosystem, the available tooling, and the exit options are all superior.&lt;/p&gt;

</description>
      <category>convex</category>
      <category>supabase</category>
      <category>firebase</category>
      <category>saasbackend</category>
    </item>
    <item>
      <title>Trigger.dev v3 vs BullMQ vs Graphile Worker 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:06:56 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/triggerdev-v3-vs-bullmq-vs-graphile-worker-2026-53l1</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/triggerdev-v3-vs-bullmq-vs-graphile-worker-2026-53l1</guid>
      <description>&lt;h1&gt;
  
  
  Trigger.dev v3 vs BullMQ vs Graphile Worker 2026
&lt;/h1&gt;

&lt;p&gt;When your application needs to run code outside the request/response cycle — send a batch of emails, process uploaded files, run ML inference, sync data with an external CRM — you reach for a background job library. In the Node.js ecosystem in 2026, three tools represent three distinct philosophies: Trigger.dev v3 (managed, TypeScript-first, no timeout limits), BullMQ (Redis-backed, battle-tested throughput), and Graphile Worker (PostgreSQL-native, no extra infrastructure).&lt;/p&gt;

&lt;p&gt;Each is genuinely good. Choosing the wrong one for your constraints costs you either performance, operational overhead, or expensive migration. This comparison gives you the data to decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trigger.dev v3&lt;/strong&gt; is the best choice if you want a managed platform, need jobs to run longer than 15 minutes, or are building AI/ML pipelines requiring long compute. &lt;strong&gt;BullMQ&lt;/strong&gt; is the best choice for high-throughput queues (1,000+ jobs/second), rate-limited task processing, or complex job dependency graphs — if you already run Redis. &lt;strong&gt;Graphile Worker&lt;/strong&gt; is the best choice if you want zero new infrastructure dependencies, already run PostgreSQL, and process fewer than 200 jobs/second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;BullMQ can process 1,000-10,000+ jobs/second; Graphile Worker tops out around 100-200/second due to PostgreSQL locking&lt;/li&gt;
&lt;li&gt;Trigger.dev v3 has no execution timeout — jobs can run for hours; BullMQ/Graphile Worker are limited by your worker process&lt;/li&gt;
&lt;li&gt;Graphile Worker requires only PostgreSQL — no Redis, no separate broker, no extra infrastructure cost&lt;/li&gt;
&lt;li&gt;BullMQ ships with Bull Board, a real-time queue monitoring UI; Trigger.dev has a built-in cloud dashboard; Graphile Worker requires custom observability tooling&lt;/li&gt;
&lt;li&gt;Trigger.dev is Apache 2.0 and self-hostable with Docker + PostgreSQL; BullMQ is MIT; Graphile Worker is MIT&lt;/li&gt;
&lt;li&gt;PostgreSQL-native scheduling (via triggers and functions) is Graphile Worker's unique capability — queue jobs from SQL, not just application code&lt;/li&gt;
&lt;li&gt;Trigger.dev v3's concurrency controls, fan-out, and realtime logs make it the most developer-friendly managed option&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Background Job Problem in 2026
&lt;/h2&gt;

&lt;p&gt;Most web applications process the same job in different ways depending on urgency and scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User clicks "export"&lt;/strong&gt; → queue a job, respond immediately with "your export is being prepared"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook arrives from Stripe&lt;/strong&gt; → process it asynchronously so your webhook endpoint doesn't time out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nightly batch&lt;/strong&gt; → process 50,000 records, send emails, update aggregates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI pipeline&lt;/strong&gt; → run OCR, extract entities, store embeddings — steps that take 2-5 minutes each&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traditional message brokers (Kafka, RabbitMQ) handle massive scale but add significant operational complexity. For most applications, a simpler job queue running against Redis or PostgreSQL is all that is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Trigger.dev v3&lt;/th&gt;
&lt;th&gt;BullMQ&lt;/th&gt;
&lt;th&gt;Graphile Worker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed (PostgreSQL self-host option)&lt;/td&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Max job duration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Worker-lifetime&lt;/td&gt;
&lt;td&gt;Worker-lifetime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Throughput&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High (managed)&lt;/td&gt;
&lt;td&gt;1,000-10,000+/sec&lt;/td&gt;
&lt;td&gt;100-200/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Step functions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Parent-child jobs&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduling (cron)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Monitoring UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud dashboard&lt;/td&gt;
&lt;td&gt;Bull Board&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-host&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (it's a library)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Extra infra needed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (cloud) or PostgreSQL&lt;/td&gt;
&lt;td&gt;Redis required&lt;/td&gt;
&lt;td&gt;PostgreSQL (existing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TypeScript support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;First-class&lt;/td&gt;
&lt;td&gt;First-class&lt;/td&gt;
&lt;td&gt;First-class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;License&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Apache 2.0&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pricing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free + pay-per-run&lt;/td&gt;
&lt;td&gt;Free (OSS)&lt;/td&gt;
&lt;td&gt;Free (OSS)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Trigger.dev v3: The Managed Job Platform
&lt;/h2&gt;

&lt;p&gt;Trigger.dev v3 was a major architectural shift from v2. Jobs no longer run inside your serverless functions — they run on Trigger.dev's dedicated compute infrastructure, which means no timeout limits. A job processing a 2GB video can run for 30 minutes without hitting Vercel's 5-minute function limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Developer Experience
&lt;/h3&gt;

&lt;p&gt;Trigger.dev v3 feels like writing normal TypeScript functions. Tasks are defined with &lt;code&gt;task()&lt;/code&gt;, scheduled with &lt;code&gt;trigger()&lt;/code&gt;, and observed in a real-time cloud dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// Define a task&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;process-upload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Retry configuration&lt;/span&gt;
  &lt;span class="nx"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;minTimeoutInMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;maxTimeoutInMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;fileKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Processing file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fileKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileKey&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 1: Download and validate&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;downloadFromS3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;File downloaded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 2: Process (can take minutes — no timeout!)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runMLPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Step 3: Save results&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;recordsProcessed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Trigger from your application&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;process-upload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fileKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;uploads/document.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fan-Out and Batch Processing
&lt;/h3&gt;

&lt;p&gt;Trigger.dev v3 supports batch triggering — create hundreds of parallel tasks in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;batch-notify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Trigger a parallel notification task for each user&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batchTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send-notification&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Concurrency and Rate Limiting
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// At most 10 concurrent runs of this task&lt;/span&gt;
  &lt;span class="nx"&gt;concurrencyLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Rate limit: max 100 per minute&lt;/span&gt;
  &lt;span class="nx"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trigger.dev v3 Pricing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Included&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;2,500 runs/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hobby&lt;/td&gt;
&lt;td&gt;$5/month&lt;/td&gt;
&lt;td&gt;25,000 runs/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$20/month&lt;/td&gt;
&lt;td&gt;100,000 runs/month + $0.002/additional run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Unlimited, SLA, SAML&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Self-hosting is free with no run limits using Docker + PostgreSQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trigger.dev v3 Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Managed cloud has some latency overhead vs self-hosted infrastructure&lt;/li&gt;
&lt;li&gt;Self-hosting requires running the Trigger.dev server (Docker + PostgreSQL)&lt;/li&gt;
&lt;li&gt;Not designed for sub-second latency requirements (job start takes ~100ms on managed)&lt;/li&gt;
&lt;li&gt;Smaller ecosystem than BullMQ for Node.js patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  BullMQ: Redis-Backed, High-Throughput Queue
&lt;/h2&gt;

&lt;p&gt;BullMQ is the successor to Bull, built on Redis streams. It has been battle-tested in production since 2019 and handles the most demanding background job workloads in the Node.js ecosystem. The core design principle is maximum throughput and feature richness using Redis as the backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining and Processing Jobs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxRetriesPerRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Create a queue&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;emailQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Add a job to the queue&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;emailQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send-welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// 5 second delay before processing&lt;/span&gt;
    &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Retry up to 3 times on failure&lt;/span&gt;
    &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exponential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;removeOnComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// Keep last 1000 completed jobs&lt;/span&gt;
    &lt;span class="na"&gt;removeOnFail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;      &lt;span class="c1"&gt;// Keep last 5000 failed jobs&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Worker processes jobs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Job &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; completed`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Job &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Parent-Child Job Dependencies
&lt;/h3&gt;

&lt;p&gt;BullMQ's most powerful feature is parent-child dependency graphs. A parent job only completes after all its children complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flowProducer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FlowProducer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Parent waits for all children&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;flowProducer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generate-report&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;queueName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;r123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-sales-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;queueName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-fetching&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sales_db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-marketing-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;queueName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-fetching&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marketing_db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-support-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;queueName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-fetching&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support_db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scheduling with Repeatable Jobs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run every day at 3am UTC&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;emailQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daily-digest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daily&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 3 * * *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UTC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BullMQ Rate Limiting
&lt;/h3&gt;

&lt;p&gt;BullMQ includes native rate limiting per queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rateLimitedQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;external-api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultJobOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;limiter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Max 100 concurrent&lt;/span&gt;
      &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Per 1000ms window&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bull Board: Monitoring UI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;serverAdapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ExpressAdapter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;createBullBoard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BullMQAdapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailQueue&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="nx"&gt;serverAdapter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/queues&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;serverAdapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRouter&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// Visit /queues in browser for real-time queue stats&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BullMQ Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Requires Redis&lt;/strong&gt; — another infrastructure dependency to run, monitor, and pay for&lt;/li&gt;
&lt;li&gt;No built-in step function support (use parent-child for workflow dependencies)&lt;/li&gt;
&lt;li&gt;Workers are long-running Node.js processes — incompatible with pure serverless deployments&lt;/li&gt;
&lt;li&gt;Redis persistence settings require careful tuning to prevent job loss on restart&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Graphile Worker: PostgreSQL-Native Job Queue
&lt;/h2&gt;

&lt;p&gt;Graphile Worker has a niche but loyal following: teams that already run PostgreSQL and want zero additional infrastructure for background jobs. Your jobs live in a PostgreSQL table. Workers poll the database and process them. No Redis, no separate broker, no extra service to monitor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Design
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pgPool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Define task handlers&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taskList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;emailClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="na"&gt;generateThumbnail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumbnail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadToS3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`thumbnails/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.jpg`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Start the worker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;pgPool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;taskList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// 5 concurrent jobs&lt;/span&gt;
  &lt;span class="na"&gt;pollInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Check for new jobs every 1 second&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Adding Jobs from Application Code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workerUtils&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;makeWorkerUtils&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Queue a job&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;workerUtils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sendEmail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Thanks for signing up!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Queue with delay&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;workerUtils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generateThumbnail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;job_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;runAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// Run in 60 seconds&lt;/span&gt;
    &lt;span class="na"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;jobKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;thumbnail-job_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// Deduplication key&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Killer Feature: Queue Jobs from SQL
&lt;/h3&gt;

&lt;p&gt;Graphile Worker's unique capability is that PostgreSQL functions and triggers can add jobs directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Queue a job automatically when a new user is inserted&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;queue_welcome_email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;PERFORM&lt;/span&gt; &lt;span class="n"&gt;graphile_worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'sendEmail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s1"&gt;'to'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'subject'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Welcome to our platform!'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Thanks for signing up, '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;user_signup_trigger&lt;/span&gt;
&lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;queue_welcome_email&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is impossible with Redis-based queues and requires application-layer glue with Trigger.dev or BullMQ.&lt;/p&gt;

&lt;h3&gt;
  
  
  Graphile Worker Throughput
&lt;/h3&gt;

&lt;p&gt;Graphile Worker is well-tested at 20-100 jobs/second on typical PostgreSQL hardware. Beyond 200 jobs/second, you start hitting PostgreSQL advisory lock contention. For high-throughput workloads, this is a hard ceiling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Graphile Worker: No Built-in Monitoring
&lt;/h3&gt;

&lt;p&gt;Graphile Worker has no official monitoring UI. Job status lives in the &lt;code&gt;graphile_worker.jobs&lt;/code&gt; table — you can query it or build custom dashboards, but there is no equivalent of Bull Board or Trigger.dev's cloud dashboard out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure Handling and Dead Letter Queues
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Trigger.dev v3&lt;/th&gt;
&lt;th&gt;BullMQ&lt;/th&gt;
&lt;th&gt;Graphile Worker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retry backoff&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Configurable per task&lt;/td&gt;
&lt;td&gt;Configurable per job&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Max attempts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DLQ behavior&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Failed jobs in dashboard&lt;/td&gt;
&lt;td&gt;Separate "failed" queue&lt;/td&gt;
&lt;td&gt;Jobs stay in table with failed status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Error inspection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Real-time in dashboard&lt;/td&gt;
&lt;td&gt;Bull Board or custom&lt;/td&gt;
&lt;td&gt;Query &lt;code&gt;_private_data&lt;/code&gt; column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retry on deploy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Observability and Scheduling Compared
&lt;/h2&gt;

&lt;p&gt;BullMQ with Bull Board and Trigger.dev's cloud dashboard are the strongest for observability. Graphile Worker requires you to query PostgreSQL directly or build your own dashboard.&lt;/p&gt;

&lt;p&gt;For cron scheduling, all three support standard cron syntax. Graphile Worker additionally supports PostgreSQL-triggered scheduling, which is unique.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Trigger.dev v3 if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need jobs to run longer than 15 minutes (AI pipelines, video processing, large file imports)&lt;/li&gt;
&lt;li&gt;You want a managed platform with no infrastructure to run&lt;/li&gt;
&lt;li&gt;You're already on a serverless stack (Vercel, Cloudflare Workers) without a persistent worker process&lt;/li&gt;
&lt;li&gt;You need realtime observability without building custom tooling&lt;/li&gt;
&lt;li&gt;Your team is TypeScript-first and values DX over raw throughput&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose BullMQ if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need high throughput: 1,000+ jobs/second&lt;/li&gt;
&lt;li&gt;You already run Redis in production&lt;/li&gt;
&lt;li&gt;You need complex job dependency graphs (parent-child flows)&lt;/li&gt;
&lt;li&gt;You require per-queue rate limiting for external API calls&lt;/li&gt;
&lt;li&gt;You're building a system that needs fine-grained job priority controls&lt;/li&gt;
&lt;li&gt;Long-running workers are acceptable in your infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose Graphile Worker if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You already run PostgreSQL and want zero new infrastructure&lt;/li&gt;
&lt;li&gt;Your job throughput is below 200/second&lt;/li&gt;
&lt;li&gt;You want the ability to queue jobs from PostgreSQL triggers or functions&lt;/li&gt;
&lt;li&gt;Your team knows SQL and prefers debugging jobs via SQL queries&lt;/li&gt;
&lt;li&gt;Operational simplicity beats monitoring convenience&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For more background job and API comparisons, see our &lt;a href="https://dev.to/blog/inngest-vs-temporal-vs-trigger-dev-2026"&gt;Inngest vs Temporal vs Trigger.dev analysis&lt;/a&gt;, &lt;a href="https://dev.to/blog/best-background-job-apis-2026"&gt;best background job APIs roundup&lt;/a&gt;, and &lt;a href="https://dev.to/blog/event-driven-apis-async-patterns-2026"&gt;event-driven API patterns guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;This article draws on official Trigger.dev v3 documentation, BullMQ documentation and GitHub discussions, Graphile Worker documentation, community benchmarks from GitHub Discussions (#922, #2458), and DEV Community performance analysis. Throughput figures are from community-reported benchmarks and should be validated against your specific workload.&lt;/p&gt;

</description>
      <category>triggerdev</category>
      <category>bullmq</category>
      <category>graphileworker</category>
      <category>backgroundjobs</category>
    </item>
    <item>
      <title>Gitea vs Forgejo vs GitLab Self-Hosted 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:06:26 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/gitea-vs-forgejo-vs-gitlab-self-hosted-2026-2pc9</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/gitea-vs-forgejo-vs-gitlab-self-hosted-2026-2pc9</guid>
      <description>&lt;h1&gt;
  
  
  Gitea vs Forgejo vs GitLab Self-Hosted 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;For self-hosted Git in 2026, the choice comes down to scale and philosophy. &lt;strong&gt;Gitea&lt;/strong&gt; and &lt;strong&gt;Forgejo&lt;/strong&gt; are nearly identical lightweight forges that run on a Raspberry Pi — the difference is governance (company vs. community non-profit) and license (MIT vs. GPL-3.0). &lt;strong&gt;GitLab CE&lt;/strong&gt; is a full DevOps platform that requires 4–8GB RAM minimum but includes native CI/CD, security scanning, container registry, and more. For most teams under 50 people who just need solid Git hosting with basic CI, Forgejo is the recommendation. For teams who need the full GitLab feature surface without the SaaS cost, GitLab CE is worth the heavier footprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gitea&lt;/strong&gt;: MIT, ~44K stars, Go — original lightweight forge, largest third-party ecosystem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgejo&lt;/strong&gt;: GPL-3.0, ~10K stars, Go — community fork of Gitea, non-profit governed, building ActivityPub federation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitLab CE&lt;/strong&gt;: MIT, ~24K stars, Ruby/Go — full DevOps platform, 4GB RAM minimum (8GB+ recommended)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fork history&lt;/strong&gt;: Forgejo split from Gitea in October 2022 after a for-profit company acquired the project without community consent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard fork&lt;/strong&gt;: Since early 2024, Forgejo is a hard fork — migrations between them may diverge on future versions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitLab gap&lt;/strong&gt;: 10–40x more resources than Gitea/Forgejo but covers CI/CD, container scanning, epics, and roadmaps natively&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Background: The Forgejo Split
&lt;/h2&gt;

&lt;p&gt;In October 2022, the Gitea project's domains and trademark were quietly transferred to a for-profit company, Gitea Ltd., without community knowledge or approval. Developers who had contributed to Gitea under the assumption it was a community project wrote an open letter objecting. When the company confirmed the direction, a group of core contributors forked the project as Forgejo, placing it under the governance of Codeberg e.V., a German non-profit.&lt;/p&gt;

&lt;p&gt;Initially Forgejo was a "soft fork" — functionally identical to Gitea, just with different governance and a more restrictive copyleft license (GPL-3.0 vs MIT). In February 2024, Forgejo formally announced it would become a &lt;strong&gt;hard fork&lt;/strong&gt;, meaning its codebase would diverge from Gitea with features like ActivityPub federation that Gitea has no plans to implement.&lt;/p&gt;

&lt;p&gt;This history matters for your decision: if you start on Gitea today, migrating to Forgejo in the future may not be a trivial upgrade once the codebases diverge further.&lt;/p&gt;




&lt;h2&gt;
  
  
  Feature Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Gitea&lt;/th&gt;
&lt;th&gt;Forgejo&lt;/th&gt;
&lt;th&gt;GitLab CE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;GPL-3.0&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Ruby + Go&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;~44K&lt;/td&gt;
&lt;td&gt;~10K&lt;/td&gt;
&lt;td&gt;~24K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM (idle)&lt;/td&gt;
&lt;td&gt;100–200MB&lt;/td&gt;
&lt;td&gt;100–200MB&lt;/td&gt;
&lt;td&gt;4–8GB+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;Gitea Actions (GitHub-compatible YAML)&lt;/td&gt;
&lt;td&gt;Forgejo Actions (GitHub-compatible YAML)&lt;/td&gt;
&lt;td&gt;GitLab CI/CD (native, extensive)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container Registry&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package Registry&lt;/td&gt;
&lt;td&gt;Yes (npm, PyPI, Maven, etc.)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issue Tracker&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes + Epics, Roadmaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wiki&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhooks&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REST API&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes + GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LDAP/SSO&lt;/td&gt;
&lt;td&gt;Yes (LDAP, OAuth)&lt;/td&gt;
&lt;td&gt;Yes (LDAP, OAuth)&lt;/td&gt;
&lt;td&gt;Yes (LDAP, SAML, OAuth, Kerberos)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mirror repos&lt;/td&gt;
&lt;td&gt;Yes (GitHub, GitLab, etc.)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security scanning&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;SAST, DAST, dependency scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ActivityPub/Federation&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;In development&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Governance&lt;/td&gt;
&lt;td&gt;Gitea Ltd. (company)&lt;/td&gt;
&lt;td&gt;Codeberg e.V. (non-profit)&lt;/td&gt;
&lt;td&gt;GitLab Inc.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted freedom&lt;/td&gt;
&lt;td&gt;High (MIT)&lt;/td&gt;
&lt;td&gt;Highest (GPL, non-profit)&lt;/td&gt;
&lt;td&gt;High (MIT)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Resource Requirements
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gitea and Forgejo
&lt;/h3&gt;

&lt;p&gt;Both Gitea and Forgejo have almost identical resource profiles — they share the same codebase for most functionality.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minimum&lt;/strong&gt;: 512MB RAM, 1 CPU core (handles personal projects and small teams)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comfortable&lt;/strong&gt;: 1–2GB RAM, 2 CPU cores (teams of 10–30)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With Actions runners&lt;/strong&gt;: Add 1–2GB RAM per concurrent runner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A $6/month VPS (2 vCPU, 2GB RAM) runs Forgejo comfortably for a team of 20. A Raspberry Pi 4 handles solo or small team use.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitLab CE
&lt;/h3&gt;

&lt;p&gt;GitLab's resource requirements are substantially higher due to its monolithic architecture running multiple services (Puma, Sidekiq, Gitaly, PostgreSQL, Redis, and more).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minimum&lt;/strong&gt;: 4GB RAM, 4 CPU cores (2 cores absolute minimum, degraded performance)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recommended for 100 users&lt;/strong&gt;: 8GB RAM, 8 vCPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recommended for 1,000 users&lt;/strong&gt;: 16GB RAM, 8 vCPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD runners&lt;/strong&gt;: Separate machines recommended; each concurrent job adds ~500MB–2GB RAM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitLab can be made to run in 2GB RAM with careful configuration (&lt;code&gt;memory_constrained_envs&lt;/code&gt; settings) but this is not suitable for production use.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker Compose
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gitea
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitea&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__DB_TYPE=postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__HOST=db:5432&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__NAME=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__USER=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__PASSWD=gitea&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./gitea:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;222:22"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:14&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_USER=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=gitea&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=gitea&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./postgres:/var/lib/postgresql/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Forgejo
&lt;/h3&gt;

&lt;p&gt;Forgejo uses the same Docker Compose structure — just swap the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;codeberg.org/forgejo/forgejo:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else is identical. This is the "soft fork" benefit that still exists today.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitLab CE
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.6'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gitlab&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitlab/gitlab-ce:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitlab&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gitlab.yourdomain.com'&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;GITLAB_OMNIBUS_CONFIG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;external_url 'https://gitlab.yourdomain.com'&lt;/span&gt;
        &lt;span class="s"&gt;gitlab_rails['gitlab_shell_ssh_port'] = 2222&lt;/span&gt;
        &lt;span class="s"&gt;# Reduce memory usage for smaller servers&lt;/span&gt;
        &lt;span class="s"&gt;unicorn['worker_processes'] = 2&lt;/span&gt;
        &lt;span class="s"&gt;postgresql['shared_buffers'] = "256MB"&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;80:80'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;443:443'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2222:22'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./gitlab/config:/etc/gitlab'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./gitlab/logs:/var/log/gitlab'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./gitlab/data:/var/opt/gitlab'&lt;/span&gt;
    &lt;span class="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;256m'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: GitLab CE takes 3–5 minutes to start on first boot as it initializes all services. Check &lt;code&gt;docker logs gitlab&lt;/code&gt; for progress.&lt;/p&gt;




&lt;h2&gt;
  
  
  CI/CD Comparison
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gitea Actions and Forgejo Actions
&lt;/h3&gt;

&lt;p&gt;Both implement GitHub Actions-compatible YAML syntax. If you have existing GitHub Actions workflows, they will largely work without modification. Runners use the same &lt;code&gt;act_runner&lt;/code&gt; tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .gitea/workflows/build.yml (works identically as .forgejo/workflows/build.yml)&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Limitations compared to GitLab CI: no built-in caching (must configure S3/MinIO), no native container scanning, no parallel matrix jobs across multiple runners without manual setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitLab CI/CD
&lt;/h3&gt;

&lt;p&gt;GitLab's CI/CD is more mature and built into the platform. Native features include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SAST (static application security testing)&lt;/li&gt;
&lt;li&gt;DAST (dynamic application security testing)&lt;/li&gt;
&lt;li&gt;Dependency scanning&lt;/li&gt;
&lt;li&gt;Container image scanning&lt;/li&gt;
&lt;li&gt;Multi-stage pipelines with artifacts&lt;/li&gt;
&lt;li&gt;Environment deployments with rollback&lt;/li&gt;
&lt;li&gt;Review apps&lt;/li&gt;
&lt;li&gt;Value stream analytics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams doing serious DevOps work, the GitLab CI/CD feature set justifies the resource cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migration Paths
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gitea → Forgejo
&lt;/h3&gt;

&lt;p&gt;Currently straightforward: Forgejo accepts Gitea database backups directly. Long-term, as the hard fork diverges, this path may require migration tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forgejo → Gitea
&lt;/h3&gt;

&lt;p&gt;Same process in reverse, but moving from GPL to MIT means you're giving up governance protections. Data format is identical at present.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gitea/Forgejo → GitLab CE
&lt;/h3&gt;

&lt;p&gt;GitLab provides a migration tool that imports from Gitea via API:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In GitLab, go to New Project → Import Project → Gitea&lt;/li&gt;
&lt;li&gt;Enter your Gitea URL and personal access token&lt;/li&gt;
&lt;li&gt;Select repositories to import (includes issues, PRs, milestones)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The import handles code, issues, pull requests, milestones, and labels. CI/CD pipelines must be manually converted from GitHub Actions YAML to GitLab CI YAML (the syntax differs).&lt;/p&gt;

&lt;h3&gt;
  
  
  GitLab CE → Gitea/Forgejo
&lt;/h3&gt;

&lt;p&gt;No official migration tool from GitLab to Gitea. The Gitea community maintains an unofficial migration script for basic repository data. Issues, CI/CD pipelines, and container registry content require manual work.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Choose Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Gitea if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need maximum third-party tool compatibility (VS Code extensions, CI integrations, bots)&lt;/li&gt;
&lt;li&gt;MIT license is important for your organization's legal requirements&lt;/li&gt;
&lt;li&gt;You want the widest plugin ecosystem&lt;/li&gt;
&lt;li&gt;You're already on Gitea and the governance change doesn't concern you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose Forgejo if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want community-governed, non-profit infrastructure&lt;/li&gt;
&lt;li&gt;GPL-3.0 copyleft licensing aligns with your philosophy&lt;/li&gt;
&lt;li&gt;You're interested in ActivityPub federation for cross-instance collaboration&lt;/li&gt;
&lt;li&gt;You're starting fresh with no existing Gitea investment&lt;/li&gt;
&lt;li&gt;You run Codeberg.org or want to align with that community&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose GitLab CE if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need integrated CI/CD without managing separate runner infrastructure&lt;/li&gt;
&lt;li&gt;Security scanning (SAST/DAST) is a hard requirement&lt;/li&gt;
&lt;li&gt;Your team is 20+ and benefits from epics, roadmaps, and advanced project management&lt;/li&gt;
&lt;li&gt;You're migrating from GitLab SaaS to cut costs&lt;/li&gt;
&lt;li&gt;You can dedicate 8+ GB RAM to the server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For more on self-hosted Git options, see our &lt;a href="https://dev.to/blog/how-to-self-host-forgejo-github-alternative-2026"&gt;guide to self-hosting Forgejo&lt;/a&gt; and the &lt;a href="https://dev.to/blog/best-open-source-developer-tools-2026"&gt;best open source GitHub alternatives&lt;/a&gt;. If you're evaluating the full self-hosted stack, the &lt;a href="https://dev.to/blog/homelab-software-stack-guide-2026"&gt;homelab software stack guide&lt;/a&gt; covers how Git hosting fits with CI/CD, container registries, and monitoring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;All three platforms handle source code, which is typically among the most sensitive assets in an organization. Each has different security postures worth understanding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gitea and Forgejo&lt;/strong&gt; have a smaller attack surface by design. Fewer services mean fewer vulnerabilities. Both projects release security patches promptly and maintain security advisories. The MIT and GPL-3.0 licenses both allow full code auditing. Running on a private network behind a reverse proxy with fail2ban for SSH brute-force protection is sufficient for most teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitLab CE&lt;/strong&gt; has more security features built in (secret detection, dependency scanning, SAST) but also a larger attack surface. The Omnibus package bundles dozens of services, any of which could have vulnerabilities. GitLab's security team is large and responsive — CVEs are typically patched within days — but you must stay current with updates. GitLab strongly recommends enabling two-factor authentication for all admin accounts and using instance-level webhook allowlists to prevent SSRF attacks.&lt;/p&gt;

&lt;p&gt;For any of the three, the most important security practices are: TLS everywhere (Let's Encrypt via Certbot or Caddy), SSH key authentication only (no password auth), regular backups, and keeping Docker images updated.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ecosystem and Integrations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gitea's ecosystem advantage&lt;/strong&gt; is real and measurable. Because Gitea has been around longer and has a larger user base, many third-party tools have native Gitea integration: VS Code extensions for PR reviews, bots for automatic labeling, migration tools from GitHub, and CI integrations. If you're using tools like Renovate (dependency update bot), Backstage (developer portal), or external CI services like Woodpecker CI, check their Gitea compatibility before committing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgejo's compatibility&lt;/strong&gt; is high today because the API is still largely identical to Gitea's. Most Gitea-compatible tools work with Forgejo. The divergence in APIs will increase as the hard fork matures, and tool vendors may prioritize Gitea compatibility over Forgejo's newer APIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitLab's ecosystem&lt;/strong&gt; is the largest, with native integrations for Kubernetes, Terraform, Vault, ArgoCD, and most major CI/CD and DevOps tools. If your organization uses enterprise tools like ServiceNow, Jira, or Salesforce, GitLab has official integrations. The tradeoff is that these integrations are more complex to configure and more likely to break on self-hosted instances after upgrades.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hosted Alternatives
&lt;/h2&gt;

&lt;p&gt;If managing your own Git server feels like too much overhead, several commercial and community-hosted options exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codeberg.org&lt;/strong&gt;: Forgejo-based, community-run, free for open source projects. The reference deployment of Forgejo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gitea.com&lt;/strong&gt;: The hosted version of Gitea, maintained by Gitea Ltd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitLab.com&lt;/strong&gt;: GitLab's SaaS offering, free tier available with 5GB storage and shared runners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sourcehut (sr.ht)&lt;/strong&gt;: Minimalist approach, git-first, open source, paid plans.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For teams that want the self-hosted control but not the ops burden, managed Gitea/Forgejo hosting providers exist on platforms like DigitalOcean Marketplace and Hetzner's App Platform.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sources consulted: 8&lt;/li&gt;
&lt;li&gt;GitHub star data from GitHub.com, March 2026&lt;/li&gt;
&lt;li&gt;Resource requirements from official documentation and community benchmarks&lt;/li&gt;
&lt;li&gt;Forgejo governance history from forgejo.org and LWN.net coverage&lt;/li&gt;
&lt;li&gt;Date: March 2026&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gitea</category>
      <category>forgejo</category>
      <category>gitlab</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Self-Host Immich: Google Photos Alternative 2026</title>
      <dc:creator>Royce</dc:creator>
      <pubDate>Wed, 18 Mar 2026 05:06:12 +0000</pubDate>
      <link>https://dev.to/royce_fabbd83cb268312e928/self-host-immich-google-photos-alternative-2026-57fk</link>
      <guid>https://dev.to/royce_fabbd83cb268312e928/self-host-immich-google-photos-alternative-2026-57fk</guid>
      <description>&lt;h1&gt;
  
  
  Self-Host Immich: The Google Photos Alternative That Actually Works in 2026
&lt;/h1&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Immich (94K+ GitHub stars, AGPL-3.0) is the closest self-hosted replacement for Google Photos that exists today. The mobile apps are native quality, face recognition works well enough to use daily, and the Docker Compose setup takes under 30 minutes. With Google Photos storage costs creeping up and growing concerns about AI training on personal photos, Immich has become the default recommendation for anyone moving off cloud photo storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;94K+ GitHub stars&lt;/strong&gt; — the fastest-growing self-hosted photo project by far, surpassing PhotoPrism and LibrePhotos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native iOS and Android apps&lt;/strong&gt; — background sync, share sheets, and offline access that feel like first-party software&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML face recognition&lt;/strong&gt; — powered by InsightFace, groups photos by person automatically with assignable names&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Natural language search&lt;/strong&gt; — uses CLIP embeddings so you can search "sunset at the beach" and find relevant photos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware-accelerated ML&lt;/strong&gt; — GPU acceleration for NVIDIA, AMD, OpenVINO, and Apple Silicon reduces indexing time by 10-20x&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Takeout import&lt;/strong&gt; — official importer preserves original dates and metadata from exports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4GB RAM minimum&lt;/strong&gt; — 8GB recommended when ML features are active&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Immich Beats Google Photos in 2026
&lt;/h2&gt;

&lt;p&gt;Google Photos changed its pricing model in 2021, ending the free unlimited storage tier. Since then, storage costs compound every year, and many users are now hitting the 15GB free limit or paying $2.99–$9.99/month for Google One. More concerning: Google's Terms of Service allow using uploaded content to improve AI services, which is a meaningful privacy consideration for family photos.&lt;/p&gt;

&lt;p&gt;Immich addresses both problems: you own the storage and the data never leaves your server.&lt;/p&gt;

&lt;p&gt;But "self-hosted photo manager" used to mean accepting significant quality gaps — slow apps, broken sync, face recognition that kind of worked. Immich closed those gaps. The mobile apps for iOS and Android are built with proper background sync that reliably uploads photos without requiring the app to be open. The face recognition groups family members accurately enough that many users have fully replaced their Google Photos workflow.&lt;/p&gt;

&lt;p&gt;The timeline view matches Google Photos almost exactly. The memories feature resurfaces photos from years past. Shared albums work. Video transcoding works. Location maps work. At 94K+ GitHub stars as of March 2026, Immich has earned its reputation as the self-hosted photo tool that doesn't require compromises.&lt;/p&gt;




&lt;h2&gt;
  
  
  Server Requirements
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Minimum (Small Library, CPU-Only ML)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;2 CPU cores&lt;/li&gt;
&lt;li&gt;4GB RAM&lt;/li&gt;
&lt;li&gt;Storage: photo library size + 20% overhead for thumbnails and ML model weights (~2GB for models)&lt;/li&gt;
&lt;li&gt;OS: any Linux distro with Docker, or macOS/Windows with Docker Desktop&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Recommended (Full AI Features, Active Use)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;4+ CPU cores&lt;/li&gt;
&lt;li&gt;8GB RAM&lt;/li&gt;
&lt;li&gt;NVMe or SSD storage (HDD works but thumbnail generation is slow)&lt;/li&gt;
&lt;li&gt;Gigabit LAN if doing initial migration of large libraries&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  GPU Acceleration (Optional)
&lt;/h3&gt;

&lt;p&gt;Immich supports hardware-accelerated ML inference for Smart Search and Face Recognition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NVIDIA&lt;/strong&gt;: CUDA via &lt;code&gt;ghcr.io/immich-app/machine-learning:release-cuda&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AMD&lt;/strong&gt;: ROCm via &lt;code&gt;ghcr.io/immich-app/machine-learning:release-rocm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intel&lt;/strong&gt;: OpenVINO via &lt;code&gt;ghcr.io/immich-app/machine-learning:release-openvino&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple Silicon&lt;/strong&gt;: CoreML via &lt;code&gt;ghcr.io/immich-app/machine-learning:release-armnn&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without GPU acceleration, ML tasks use CPU. On a 4-core machine, indexing 10,000 photos takes approximately 2–4 hours. With a mid-range GPU, the same job completes in 10–20 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker Compose Setup
&lt;/h2&gt;

&lt;p&gt;Create a working directory and download the official config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;immich &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;immich
wget https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
wget https://github.com/immich-app/immich/releases/latest/download/.env.example
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit &lt;code&gt;.env&lt;/code&gt; with your settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Required: set your upload path&lt;/span&gt;
&lt;span class="nv"&gt;UPLOAD_LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./library

&lt;span class="c"&gt;# Required: generate a random secret&lt;/span&gt;
&lt;span class="nv"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-strong-password-here

&lt;span class="c"&gt;# Optional: timezone&lt;/span&gt;
&lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;America/New_York
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full &lt;code&gt;docker-compose.yml&lt;/code&gt; from the official repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;immich-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich_server&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${UPLOAD_LOCATION}:/usr/src/app/upload&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;2283:2283&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;disable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="na"&gt;immich-machine-learning&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich_machine_learning&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/immich-app/machine-learning:${IMMICH_VERSION:-release}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;model-cache:/cache&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;disable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich_redis&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-cli ping || exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;

  &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich_postgres&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_USERNAME}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_DATABASE_NAME}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_INITDB_ARGS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--data-checksums'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${DB_DATA_LOCATION}:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
        &lt;span class="s"&gt;pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USERNAME}" || exit 1;&lt;/span&gt;
        &lt;span class="s"&gt;Sleeptime=15;&lt;/span&gt;
        &lt;span class="s"&gt;retries=5;&lt;/span&gt;
        &lt;span class="s"&gt;while [ $retries -gt 0 ]; do&lt;/span&gt;
          &lt;span class="s"&gt;sleep $Sleeptime;&lt;/span&gt;
          &lt;span class="s"&gt;retries=$((retries - 1));&lt;/span&gt;
          &lt;span class="s"&gt;Sleeptime=10;&lt;/span&gt;
          &lt;span class="s"&gt;pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USERNAME}" &amp;amp;&amp;amp; exit 0;&lt;/span&gt;
        &lt;span class="s"&gt;done;&lt;/span&gt;
        &lt;span class="s"&gt;exit 1&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;model-cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start Immich:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Access the web UI at &lt;code&gt;http://your-server-ip:2283&lt;/code&gt;. On first launch, create an admin account.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reverse Proxy Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Nginx
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;photos.yourdomain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="nv"&gt;$server_name$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;photos.yourdomain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/photos.yourdomain.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/photos.yourdomain.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;50000M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:2283&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# Required for video streaming&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;600s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;send_timeout&lt;/span&gt; &lt;span class="s"&gt;600s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Traefik (Docker Label)
&lt;/h3&gt;

&lt;p&gt;Add these labels to the &lt;code&gt;immich-server&lt;/code&gt; service in your compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.immich.rule=Host(`photos.yourdomain.com`)"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.immich.entrypoints=websecure"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.immich.tls.certresolver=letsencrypt"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.immich.loadbalancer.server.port=2283"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Migrating from Google Photos
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Export with Google Takeout
&lt;/h3&gt;

&lt;p&gt;Go to &lt;a href="https://takeout.google.com" rel="noopener noreferrer"&gt;takeout.google.com&lt;/a&gt;, select only Google Photos, and export. Large libraries will be split into multiple &lt;code&gt;.zip&lt;/code&gt; files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Use the Official Immich CLI Importer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install the CLI&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @immich/cli

&lt;span class="c"&gt;# Log in to your instance&lt;/span&gt;
immich login https://photos.yourdomain.com your@email.com yourpassword

&lt;span class="c"&gt;# Import a Google Takeout directory (preserves dates from JSON sidecars)&lt;/span&gt;
immich upload &lt;span class="nt"&gt;--recursive&lt;/span&gt; /path/to/takeout/Google&lt;span class="se"&gt;\ &lt;/span&gt;Photos/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--recursive&lt;/code&gt; flag handles the nested folder structure. The CLI reads &lt;code&gt;.json&lt;/code&gt; metadata files from Takeout exports and sets correct timestamps. Without this step, all photos would show today's date.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Verify and Clean Up
&lt;/h3&gt;

&lt;p&gt;After import, use the Immich web UI to check the Timeline view — photos should appear in chronological order going back to your earliest photos. Run a spot-check on a few years to confirm dates imported correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backup Strategy
&lt;/h2&gt;

&lt;p&gt;Immich stores photos in a flat directory structure under &lt;code&gt;UPLOAD_LOCATION&lt;/code&gt;. Back this up like any other important data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommended approach with restic:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install restic&lt;/span&gt;
apt &lt;span class="nb"&gt;install &lt;/span&gt;restic

&lt;span class="c"&gt;# Initialize a repository (example: local backup to external drive)&lt;/span&gt;
restic init &lt;span class="nt"&gt;--repo&lt;/span&gt; /mnt/backup/immich-photos

&lt;span class="c"&gt;# Backup photos and database dump&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;immich_postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres immich &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/immich-db.sql
restic &lt;span class="nt"&gt;-r&lt;/span&gt; /mnt/backup/immich-photos backup &lt;span class="se"&gt;\&lt;/span&gt;
  /path/to/immich/library &lt;span class="se"&gt;\&lt;/span&gt;
  /tmp/immich-db.sql

&lt;span class="c"&gt;# Schedule daily backups (cron)&lt;/span&gt;
0 2 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /path/to/immich-backup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Postgres database is small (metadata, thumbnails paths, face data) — the heavy backup is the photo library itself. Store the DB dump alongside the library backup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance Tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Move ML to a separate machine.&lt;/strong&gt; The &lt;code&gt;immich-machine-learning&lt;/code&gt; container can run on a different server via the &lt;code&gt;MACHINE_LEARNING_HOST&lt;/code&gt; environment variable. Useful if your NAS is low-power but you have a desktop with a GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Enable hardware transcoding for video.&lt;/strong&gt; Edit your &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IMMICH_MEDIA_LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/src/app/upload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the server settings UI, set Hardware Acceleration to NVENC (NVIDIA) or VAAPI (Intel/AMD).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Set a thumbnail quality tier.&lt;/strong&gt; In Administration &amp;gt; Settings &amp;gt; Thumbnail, lower resolution saves disk space with minimal visual impact for library browsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Exclude duplicates before import.&lt;/strong&gt; Run the Google Takeout folders through a deduplication tool like &lt;code&gt;fdupes&lt;/code&gt; before importing to avoid filling storage with duplicates from multiple Takeout exports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Schedule ML jobs off-peak.&lt;/strong&gt; In Administration &amp;gt; Jobs, set face detection and smart search to run at night to avoid impacting app responsiveness during day use.&lt;/p&gt;




&lt;h2&gt;
  
  
  Immich vs PhotoPrism vs LibrePhotos
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Immich&lt;/th&gt;
&lt;th&gt;PhotoPrism&lt;/th&gt;
&lt;th&gt;LibrePhotos&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Stars&lt;/td&gt;
&lt;td&gt;94K+&lt;/td&gt;
&lt;td&gt;36K+&lt;/td&gt;
&lt;td&gt;3K+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile Apps&lt;/td&gt;
&lt;td&gt;Native iOS + Android&lt;/td&gt;
&lt;td&gt;PWA only&lt;/td&gt;
&lt;td&gt;PWA only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Face Recognition&lt;/td&gt;
&lt;td&gt;InsightFace (good)&lt;/td&gt;
&lt;td&gt;TensorFlow (decent)&lt;/td&gt;
&lt;td&gt;Face.ai (basic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Natural Language Search&lt;/td&gt;
&lt;td&gt;CLIP embeddings&lt;/td&gt;
&lt;td&gt;CLIP&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU Acceleration&lt;/td&gt;
&lt;td&gt;NVIDIA/AMD/Intel/Apple&lt;/td&gt;
&lt;td&gt;NVIDIA/Apple&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Compose&lt;/td&gt;
&lt;td&gt;Official, simple&lt;/td&gt;
&lt;td&gt;Complex, many options&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a deeper comparison, see our full &lt;a href="https://dev.to/blog/immich-vs-photoprism-vs-librephotos-2026"&gt;Immich vs PhotoPrism vs LibrePhotos breakdown&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;Immich is the only self-hosted photo manager that can genuinely replace Google Photos without meaningful quality regressions. The native apps, reliable background sync, face recognition, and CLIP search all work at a level that would feel at home in a commercial product. For anyone paying for Google One storage or concerned about privacy, the 30-minute setup investment pays off immediately.&lt;/p&gt;

&lt;p&gt;If you're evaluating self-hosted cloud storage more broadly, see our &lt;a href="https://dev.to/blog/best-self-hosted-alternatives-google-drive-2026"&gt;best self-hosted Google Drive alternatives guide&lt;/a&gt; and the &lt;a href="https://dev.to/blog/homelab-software-stack-guide-2026"&gt;homelab software stack guide&lt;/a&gt; for the full picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Setup Issues
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Photos not backing up from mobile&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most common issue after initial setup. Check three things: (1) The mobile app requires the server to be accessible on your local network or via a domain with valid TLS — a plain &lt;code&gt;http://192.168.x.x:2283&lt;/code&gt; URL works on Android but iOS requires HTTPS for background sync. (2) Battery optimization settings on Android kill background processes — add Immich to the battery exclusion list. (3) On iOS, grant Immich "Full Access" to Photos in Settings &amp;gt; Privacy &amp;gt; Photos, not just "Selected Photos".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ML jobs running but face recognition not appearing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After initial install, Smart Search and Face Detection jobs run in the background. On a large library, this takes hours or days. Progress is visible in Administration &amp;gt; Jobs. If jobs show "waiting" and never start, check that the &lt;code&gt;immich-machine-learning&lt;/code&gt; container is running: &lt;code&gt;docker ps | grep machine-learning&lt;/code&gt;. The ML container downloads model weights (~300MB) on first run and needs internet access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database migration errors on upgrade&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Always stop the stack before upgrading (&lt;code&gt;docker compose down&lt;/code&gt;) and never skip major versions. Immich uses Postgres migrations tied to specific releases. Check the release notes on GitHub for any breaking changes before pulling a new tag. Keeping the &lt;code&gt;IMMICH_VERSION&lt;/code&gt; pinned to a specific release (e.g., &lt;code&gt;v1.100.0&lt;/code&gt;) prevents unintended upgrades when running &lt;code&gt;docker compose pull&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage running out faster than expected&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Immich generates thumbnails at multiple resolutions for the web UI and mobile apps. For a 50GB photo library, expect 10–20GB of additional storage for thumbnails and transcoded videos. The ML model weights add another 2–3GB. Plan for 130–140% of your raw photo library size as the total storage requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video playback failing in browser&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Immich transcodes videos to web-compatible formats in the background. Original iPhone HEVC videos won't play in Chrome until transcoded. The transcoding queue is visible in Administration &amp;gt; Jobs &amp;gt; Video Conversion. On CPU-only servers, this is slow — a 4-minute 4K video can take 10–20 minutes to transcode. GPU hardware encoding via NVENC dramatically reduces this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mobile App Features Worth Knowing
&lt;/h2&gt;

&lt;p&gt;The Immich mobile apps are more capable than the feature list suggests. A few features that aren't obvious:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background sync configuration&lt;/strong&gt;: In the app's Backup settings, you can configure sync to happen only on WiFi, only when charging, and only during specific hours. For large libraries, throttling the initial backup prevents your home network from being saturated for days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared albums&lt;/strong&gt;: You can share albums with other Immich users on the same instance, or generate shareable links for external viewers (no account required). The external share links support optional passwords and expiry dates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memories&lt;/strong&gt;: Every day, Immich surfaces "On this day" memories from past years — the same feature that drives engagement in Google Photos. This runs automatically once your library is indexed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Map view&lt;/strong&gt;: If your photos have GPS metadata (all modern smartphones embed this), Immich displays them on an interactive map. Useful for finding photos from a specific trip or location.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack similar photos&lt;/strong&gt;: Immich can group burst shots and similar photos into stacks, showing only the best one in the timeline. This significantly reduces visual clutter for mobile photographers who take multiple shots of the same scene.&lt;/p&gt;




&lt;h2&gt;
  
  
  Immich for Families
&lt;/h2&gt;

&lt;p&gt;Immich is particularly well-suited for family photo management because of its sharing features. Each family member can have their own account, with their own library of automatically-synced photos. You can create shared albums that multiple people contribute to — the family vacation album that both parents and kids add photos to. The "Memories" feature resurfaces shared moments: on a photo's anniversary, Immich shows it to everyone who has access.&lt;/p&gt;

&lt;p&gt;The admin account can manage storage quotas per user, preventing one family member from filling the disk. For families migrating from iCloud Photos, Immich's iOS app provides background sync that replaces the iCloud Photos Library seamlessly — photos upload to your server instead of Apple's.&lt;/p&gt;

&lt;p&gt;One important note: Immich is not designed for long-term cold archival. The database and thumbnail structure means you need to run the Docker stack to browse your library. For true long-term archival (disaster-proof, offline copies), supplement Immich with a periodic backup of the raw &lt;code&gt;library/&lt;/code&gt; directory to external storage or a cold storage provider. Your Immich data plus a raw backup of the upload directory gives you both a functional browsing interface and a recovery option if the server dies.&lt;/p&gt;




&lt;h2&gt;
  
  
  Updating Immich
&lt;/h2&gt;

&lt;p&gt;Immich releases frequently — roughly weekly minor updates. The recommended update process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/immich
docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Monitor the release notes at &lt;a href="https://github.com/immich-app/immich/releases" rel="noopener noreferrer"&gt;github.com/immich-app/immich/releases&lt;/a&gt; before upgrading. Major releases (1.x → 1.(x+1)) may require database migrations that take time on large libraries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sources consulted: 8&lt;/li&gt;
&lt;li&gt;GitHub star data from GitHub.com, March 2026&lt;/li&gt;
&lt;li&gt;Docker Compose config from official Immich releases&lt;/li&gt;
&lt;li&gt;Date: March 2026&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>immich</category>
      <category>selfhosted</category>
      <category>photomanagement</category>
      <category>googlephotos</category>
    </item>
  </channel>
</rss>
