Dlaczego Elixir, a nie Go, Node.js czy Java? Uczciwe porównanie

Kiedy mówimy klientom, że budujemy systemy w Elixirze, słyszymy zawsze to samo pytanie: „Dlaczego nie w czymś popularniejszym?" Fair enough. Go ma Google za sobą, Node.js ma największy ekosystem na świecie, Java działa w bankach od 25 lat. Dlaczego wybieramy język, o którym większość dyrektorów IT nigdy nie słyszała?

Ten artykuł to nie manifest fanboya. Każda z tych technologii jest dobra - w swoim kontekście. Naszym kontekstem są systemy biznesowe, które muszą działać 24/7, obsługiwać wielu użytkowników jednocześnie i rozwijać się latami bez przepisywania. W tym kontekście Elixir wygrywa - i zaraz pokażę dlaczego.

Cztery technologie w jednej tabeli

Zanim wejdziemy w szczegóły, szybki przegląd:

CechaElixirGoNode.jsJava
Rok powstania2012200920091995
Maszyna wirtualnaBEAM (Erlang)Natywny binaryV8 (Chrome)JVM
TypowanieDynamiczneStatyczneDynamiczneStatyczne
WspółbieżnośćProcesy BEAM (preemptive)Goroutines (cooperative)Event loop (single-thread)Wątki OS + virtual threads
ParadygmatFunkcyjnyImperatywnyMulti-paradygmatObiektowy
Garbage collectorPer-process (< 1 ms)Stop-the-world (< 1 ms)Stop-the-world (do 100+ ms)Stop-the-world (do 500+ ms)
Hot code reloadWbudowanyBrakBrakCzęściowy (JRebel)
Firma za projektemSpołecznośćIBM/GoogleOpenJS FoundationOracle

Problem nr 1: współbieżność

Każdy system biznesowy to system współbieżny. 50 użytkowników pracuje jednocześnie, generują zamówienia, aktualizują stany magazynowe, wysyłają powiadomienia. Pytanie nie brzmi „czy potrzebujesz współbieżności", tylko jak Twoja technologia ją obsługuje.

Node.js - jeden wątek, jedno wąskie gardło

Node.js działa na jednym wątku z event loopem. Świetnie radzi sobie z operacjami I/O (czytanie z bazy, HTTP requesty), ale:

// Node.js - problem z CPU-bound operacjami
app.get('/raport', async (req, res) => {
  // To BLOKUJE cały serwer na czas generowania
  const raport = generujRaportPDF(dane); // 3 sekundy CPU
  res.send(raport);
});

// Podczas tych 3 sekund ŻADEN inny request nie jest obsługiwany
// 50 użytkowników czeka, aż jeden raport się wygeneruje

Rozwiązanie? Worker threads, klaster procesów, kolejka zadań. Ale to dodatkowa złożoność, której nie miałbyś w Elixirze:

# Elixir - każdy request w osobnym procesie
def generate_report(conn, params) do
  # To NIE blokuje innych requestów
  # BEAM automatycznie przełącza procesy co ~4000 redukcji
  report = ReportGenerator.generate_pdf(data)
  send_download(conn, report)
end

# 50 użytkowników generuje raporty jednocześnie
# Każdy w swoim procesie, scheduler równomiernie dzieli CPU

Go - goroutines, ale bez izolacji

Go ma goroutines - lekkie „zielone wątki", świetne do współbieżności. Ale jest fundamentalna różnica:

// Go - goroutine z panikiem zabija cały program
func handleRequest(w http.ResponseWriter, r *http.Request) {
    go func() {
        // Jeśli ta goroutine spowoduje panic...
        result := processOrder(r)
        // ...i nikt nie napisał recover()...
        // ...cały serwer pada
    }()
}

// Musisz RĘCZNIE obsługiwać każdy możliwy błąd
// Musisz RĘCZNIE pisać recover() w każdej goroutine
// Musisz RĘCZNIE zarządzać lifecycle goroutines
# Elixir - crash jednego procesu nie wpływa na resztę
def handle_request(conn, params) do
  # Jeśli ten proces upadnie...
  result = OrderProcessor.process(params)
  # ...supervisor automatycznie go zrestartuje
  # ...żaden inny użytkownik nie odczuje problemu
  # ...błąd zostanie zalogowany
  json(conn, result)
end

# Zero ręcznej obsługi - supervisor tree robi to za Ciebie

W Go musisz sam zbudować to, co BEAM daje out-of-the-box: izolację błędów, restart, nadzór procesów.

Java - wątki, które kosztują

Java tradycyjnie używa wątków OS - ciężkich, kosztownych pamięciowo:

ZasóbProces BEAMGoroutine (Go)Virtual Thread (Java 21+)Wątek OS (Java classic)
Pamięć startowa~2.6 KB~8 KB~1 KB512 KB - 1 MB
Limit na serwer1 000 000+100 000+1 000 000+5 000 - 10 000
Izolacja błędówPełnaBrakBrakBrak
GC per-processTakNieNieNie
Preemptive schedulingTakNieNieTak (OS)

Java 21 wprowadziła virtual threads (Project Loom), rozwiązując problem skalowalności. Ale nie rozwiązuje problemu izolacji - exception w jednym wątku może uszkodzić współdzielony stan.

Problem nr 2: niezawodność

Niezawodność to nie jest „nice to have" w systemie, od którego zależy Twoja firma.

BEAM: 99.9999999% uptime

BEAM (maszyna wirtualna Erlanga/Elixira) został zaprojektowany przez Ericssona do systemów telekomunikacyjnych, gdzie minuta downtime'u = miliony utraconych połączeń. To nie jest framework webowy, do którego doklejono niezawodność - niezawodność jest fundamentem.

Co to oznacza w praktyce:

Izolacja procesów - każdy proces BEAM ma własną pamięć, własny garbage collector, własny stos. Crash jednego nie wpływa na żaden inny:

# Symulacja: 1 na 100 requestów powoduje błąd
defmodule OrderController do
  def create(conn, params) do
    # Jeśli to upadnie - tylko TEN request zwróci 500
    # Pozostałe 99 requestów działa normalnie
    # Proces zostaje zrestartowany w mikrosekundach
    order = Orders.create!(params)
    json(conn, order)
  end
end

Supervisor trees - hierarchia nadzorców, którzy automatycznie restartują procesy:

# Supervisor automatycznie restartuje uszkodzone procesy
children = [
  {Phoenix.Endpoint, []},           # Web server
  {MyApp.OrderProcessor, []},       # Przetwarzanie zamówień
  {MyApp.NotificationSender, []},   # Powiadomienia
  {MyApp.ReportScheduler, []}       # Raporty cykliczne
]

# Jeśli NotificationSender padnie:
# 1. Supervisor to wykrywa (mikrosekundy)
# 2. Restartuje TYLKO NotificationSender
# 3. Web server, zamówienia i raporty działają bez przerwy
# 4. Log z błędem trafia do monitoringu

Co to oznacza dla Go, Node.js i Java

Scenariusz awariiElixirGoNode.jsJava
Nieobsłużony wyjątekRestart procesu (~μs)Panic → crash programuCrash programuWątek umiera, stan niespójny
Memory leak w jednej funkcjiIzolowany do procesuWpływa na cały programWpływa na cały programWpływa na cały program
Nieskończona pętlaScheduler przerywa po 4000 redukcjiBlokuje goroutine (i może wątek OS)Blokuje cały event loopBlokuje wątek
Potrzeba restartu komponentuHot restart przez supervisoraRestart całego programuRestart całego programuRestart programu (lub JRebel)
Aktualizacja bez downtime'uHot code reloadBlue-green deployBlue-green deployRolling restart

Problem nr 3: garbage collector

To temat, który programiści ignorują, a biznes odczuwa - bo to GC powoduje te losowe „zawieszenia" systemu.

Java - słoń w pokoju

Java ma prawdopodobnie najlepsze garbage collectory na świecie (ZGC, Shenandoah, G1). Problem w tym, że operują na całej pamięci procesu jednocześnie:

  • Aplikacja Java z 8 GB heap → GC musi przejrzeć 8 GB
  • Pauza GC: typowo 10-50 ms, ale zdarzają się piki 200-500 ms
  • Użytkownik odczuwa to jako losowe „zacinanie się" systemu

Node.js - mniejszy heap, ale ten sam problem

V8 ogranicza heap do ~1.5 GB per proces. Mniejszy heap = krótsze pauzy GC (1-10 ms). Ale jeśli potrzebujesz więcej pamięci, musisz uruchomić wiele procesów - a to komplikuje współdzielenie stanu.

Go - bardzo dobre GC, ale globalne

Go ma jedne z najlepszych pausów GC w branży (< 1 ms). Ale nadal jest globalny - operuje na całym heapie programu.

Elixir/BEAM - GC, który nie istnieje (prawie)

BEAM rozwiązuje problem GC w sposób fundamentalnie inny: każdy proces ma własny garbage collector:

GC zbiera TYLKO pamięć P1 (kilka KB). P2, P3, P4 nie są zatrzymywane.

Rezultat:

  • Pauza GC: < 1 ms - zawsze, niezależnie od ilości pamięci
  • Brak „stop-the-world" - GC jednego procesu nie wpływa na inne
  • Przewidywalna latencja - p99 bliskie p50

Dla systemu biznesowego to oznacza: brak losowych zawieszek, brak „system się przycina o 14:00 gdy wszyscy wracają z lunchu".

Problem nr 4: real-time i WebSocket

Nowoczesne systemy biznesowe potrzebują real-time: live dashboardy, powiadomienia, aktualizacje stanów. Jak to wygląda w każdej technologii:

Node.js + Socket.io

// Działa, ale skalowalność jest problemem
const io = require('socket.io')(server);

io.on('connection', (socket) => {
  socket.join(`company:${socket.companyId}`);

  socket.on('order:update', (data) => {
    // Emituj do wszystkich w firmie
    io.to(`company:${data.companyId}`).emit('order:changed', data);
  });
});

// Problem: 10 000 połączeń = 10 000 eventów w JEDNYM wątku
// Potrzebujesz Redis Pub/Sub do wielu instancji
// Potrzebujesz sticky sessions na load balancerze
// Potrzebujesz osobnej infrastruktury dla WebSocket

Phoenix LiveView - real-time bez JavaScriptu

# LiveView - real-time UI renderowany na serwerze
defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    # Subskrybuj aktualizacje zamówień dla tej firmy
    Phoenix.PubSub.subscribe(MyApp.PubSub, "company:#{socket.assigns.company_id}")

    orders = Orders.list_today(socket.assigns.company_id)
    {:ok, assign(socket, orders: orders, total: calculate_total(orders))}
  end

  # Gdy przyjdzie nowe zamówienie - UI aktualizuje się automatycznie
  def handle_info({:order_created, order}, socket) do
    orders = [order | socket.assigns.orders]
    {:noreply, assign(socket, orders: orders, total: calculate_total(orders))}
  end

  # Zero JavaScriptu, zero Socket.io, zero Redisa
  # BEAM zarządza 100 000+ połączeń na jednym serwerze
  # PubSub działa w klastrze BEAM bez zewnętrznych zależności
end
AspektNode.js + React + Socket.ioPhoenix LiveView
Pliki do napisaniaBackend + Frontend + WebSocket1 plik LiveView
Zewnętrzne zależnościRedis, Socket.ioBrak
Połączenia na serwer~10 000~100 000+
Stan synchronizacjiRęczny (Redux/React Query)Automatyczny
SEOWymaga SSRWbudowany (server-rendered)
Czas implementacji3-5 dni0.5-1 dzień

Problem nr 5: ekosystem i produktywność

„Ale Node.js ma npm z milionem paczek!"

Tak. I to jest jednocześnie siła i słabość:

node_modules dla typowego projektu Express:
├── 847 paczek
├── 234 MB na dysku
├── 12 paczek z known vulnerabilities
├── 3 paczki porzucone (ostatni commit 2+ lata temu)
└── left-pad incident (pamiętacie?)

Elixir ma mniejszy ekosystem (~18 000 paczek na Hex vs ~2 000 000 na npm), ale:

  • PostgreSQL zastępuje Redisa, Kafkę, MongoDB - mniej zależności od startu
  • Oban zastępuje Bull/Sidekiq - kolejki w bazie, nie w osobnym serwisie
  • Phoenix PubSub zastępuje Socket.io + Redis - wbudowany w framework
  • LiveView zastępuje React/Vue + API - mniej kodu, mniej punktów awarii
FunkcjaNode.js ecosystemElixir ecosystem
Web frameworkExpress + middlewarePhoenix (batteries included)
Real-timeSocket.io + RedisPhoenix PubSub (wbudowany)
Background jobsBull + RedisOban (PostgreSQL)
WebSocket UIReact + Socket.ioLiveView (wbudowany)
MailerNodemailerSwoosh
AuthPassport.jsmix phx.gen.auth
Łączna liczba zależności15-30 paczek5-10 paczek
Zewnętrzna infrastrukturaRedis, ewentualnie KafkaTylko PostgreSQL

Mniej zależności = mniej podatności, mniej aktualizacji, mniej rzeczy, które mogą się zepsuć.

„Ale Java ma Spring Boot!"

Spring Boot to potężny framework. Ale porównajmy produktywność:

// Java/Spring - CRUD endpoint dla zamówień
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private OrderMapper orderMapper;

    @GetMapping
    public ResponseEntity<Page<OrderDTO>> listOrders(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        Page<Order> orders = orderRepository.findAll(
            PageRequest.of(page, size, Sort.by("createdAt").descending())
        );
        return ResponseEntity.ok(orders.map(orderMapper::toDTO));
    }

    @PostMapping
    @Transactional
    public ResponseEntity<OrderDTO> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        Order order = orderMapper.toEntity(request);
        order = orderRepository.save(order);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(orderMapper.toDTO(order));
    }
}

// + OrderRepository.java (interface)
// + Order.java (entity, 80+ linii z JPA annotations)
// + OrderDTO.java (record/class)
// + CreateOrderRequest.java (record/class)
// + OrderMapper.java (MapStruct interface)
// = 6 plików, ~200 linii kodu
# Elixir/Phoenix - to samo
defmodule MyAppWeb.OrderController do
  use MyAppWeb, :controller

  def index(conn, params) do
    page = Orders.list_orders(params)
    render(conn, :index, page: page)
  end

  def create(conn, %{"order" => order_params}) do
    case Orders.create_order(order_params) do
      {:ok, order} ->
        conn
        |> put_status(:created)
        |> render(:show, order: order)

      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(:error, changeset: changeset)
    end
  end
end

# + Order schema (Ecto, ~30 linii)
# + Orders context (~40 linii)
# = 3 pliki, ~80 linii kodu

60% mniej kodu, mniej plików, mniej boilerplate'u. Przy 50 endpointach ta różnica to tygodnie zaoszczędzonego czasu.

„Ale Go jest szybszy!"

W raw throughput - tak. Go kompiluje się do kodu maszynowego, Elixir działa na maszynie wirtualnej. Benchmark „Hello World" Go wygra.

Ale systemy biznesowe to nie benchmarki „Hello World":

Typowy request w systemie biznesowym:
1. Parsuj request             → 0.01 ms (nieistotne)
2. Zwaliduj dane              → 0.1 ms  (nieistotne)
3. Zapytanie do PostgreSQL    → 2-15 ms (DOMINUJE)
4. Logika biznesowa           → 0.5 ms  (nieistotne)
5. Serializacja odpowiedzi   → 0.05 ms (nieistotne)
─────────────────────────────────────
Łącznie:                        ~3-16 ms

Czy Go wykona krok 1 w 0.005 ms zamiast 0.01 ms?
Tak. Czy użytkownik to odczuje? Nie.

95% czasu requestu to czekanie na bazę danych i I/O. Różnica w szybkości CPU między Go a Elixirem jest irrelewantna w typowym systemie biznesowym.

Gdzie Go naprawdę wygrywa:

  • Narzędzia CLI (kompilacja do jednego binarki)
  • Proxy / API gateway (czysty throughput)
  • Kryptografia, kompresja, przetwarzanie binarne

Gdzie to nie ma znaczenia:

  • Systemy CRUD
  • Dashboardy i panele administracyjne
  • Systemy z bazą danych jako bottleneck

Kiedy NIE wybralibyśmy Elixira

Uczciwie - Elixir nie jest najlepszym wyborem wszędzie:

ScenariuszLepsza technologiaDlaczego
Narzędzie CLIGo / RustJeden binary, brak runtime
Gra 3D, silnik graficznyC++ / RustWymagania CPU, brak GC
Aplikacja mobilnaKotlin / SwiftNatywne SDK, UX
Data science, MLPythonEkosystem (NumPy, PyTorch)
System embedded, IoTRust / CBrak runtime, bare metal
Prosty landing pageHTML/CSS / Next.jsOverkill na statyczną stronę

Kiedy Elixir wygrywa

ScenariuszDlaczego Elixir
System ERP/CRMWspółbieżność, niezawodność, LiveView
Platforma SaaSMulti-tenant, izolacja, hot reload
System e-commerceReal-time stany magazynowe, zamówienia
Dashboard / monitoringLiveView, PubSub, zero Redisa
Platforma komunikacyjnaBEAM stworzony do telekomów
System integracyjnyGenServer, nadzór, retry, circuit breaker
System z wysokim SLASupervisor trees, hot code reload

Porównanie kosztów utrzymania (3 lata)

Ostatecznie technologia to koszt. Policzmy TCO dla systemu z 50 użytkownikami:

SkładnikNode.js + ReactJava + SpringGo + ReactElixir + LiveView
Serwery (infra)33-421
Zewnętrzna infra (Redis, itp.)RedisRedis, ewentualnie KafkaRedisBrak
Developerzy do budowy3 (2 back + 1 front)3 (2 back + 1 front)3 (2 back + 1 front)2 (fullstack)
Koszt infrastruktury/mies.~4 500 PLN~5 500 PLN~3 500 PLN~2 000 PLN
Czas implementacji (MVP)4-5 mies.5-7 mies.4-5 mies.3-4 mies.
Linie kodu (szacunkowo)~40 000~60 000~35 000~20 000

Mniej kodu = mniej bugów = mniej czasu na utrzymanie = niższy koszt.

Koszt 3-letniNode.js + ReactJava + SpringGo + ReactElixir + LiveView
Infrastruktura162 000 PLN198 000 PLN126 000 PLN72 000 PLN
Development (MVP)300 000 PLN420 000 PLN300 000 PLN200 000 PLN
Utrzymanie (rocznie)120 000 PLN150 000 PLN120 000 PLN80 000 PLN
Łącznie 3 lata822 000 PLN1 068 000 PLN786 000 PLN512 000 PLN

Różnica między Elixirem a Javą: 556 000 PLN w 3 lata. To nie jest zaokrąglenie - to pół miliona złotych.

„Ale nie znajdę programistów Elixira!"

Najczęstsza obiekcja. Odpowiedź:

Nie musisz ich szukać. Zatrudniasz software house (jak nas), który ma zespół. Ale nawet gdybyś budował zespół wewnętrzny:

  1. Programista Elixira uczy się języka w 2-4 tygodnie - jeśli zna dowolny język programowania. Elixir jest prosty składniowo.

  2. Mniejszy zespół - tam, gdzie potrzebujesz 3 developerów Java + 1 frontend, wystarczą 2 osoby z Elixirem i LiveView.

  3. Elixir przyciąga dobrych programistów - ludzie, którzy aktywnie wybierają mniej popularną technologię, zwykle są bardziej doświadczeni i zmotywowani.

  4. Rynek rośnie - Stack Overflow Survey 2024: Elixir w top 5 „most loved languages" szósty rok z rzędu. Programiści chcą w nim pisać.

  5. Ekosystem Erlanga - BEAM to nie nisza. WhatsApp (2 mld użytkowników, 50 inżynierów), Discord (150 mln użytkowników), RabbitMQ, Riak, CouchDB - wszyscy na BEAM.

Podsumowanie: kiedy co wybrać

Potrzebujesz systemu biznesowego 24/7?
├── Tak → Elixir (niezawodność, supervisor trees, hot reload)

Potrzebujesz real-time UI?
├── Tak → Elixir + LiveView (zero dodatkowej infrastruktury)

Budujesz narzędzie CLI lub proxy?
├── Tak → Go (jeden binary, raw speed)

Masz istniejący zespół Java i korporacyjne wymagania?
├── Tak → Java/Spring (znany stack, enterprise support)

Budujesz prosty CRUD z minimalnym budżetem?
├── Tak → Node.js (najłatwiej znaleźć developerów)

Budujesz system, który musi działać latami bez przepisywania?
└── Elixir (BEAM działa od 1986, backward compatibility)

Nie wybieramy Elixira, bo jest modny. Wybieramy go, bo dla systemów biznesowych nie ma lepszego narzędzia. Mniej serwerów, mniej kodu, mniej awarii, mniej kosztów. Liczby mówią same za siebie.


Chcesz zobaczyć, jak Elixir sprawdzi się w Twoim projekcie? Porozmawiajmy - pokażemy konkretne kalkulacje dla Twojego przypadku.