A battle-tested developer’s controversial take on why JavaScript’s darling can’t compete with Elixir’s rising star
The JavaScript community won’t like this, but it’s time someone said it: Phoenix LiveView has made Express.js obsolete for serious web applications.
I’ll be honest — I used to be one of those developers who swore by Node.js. “JavaScript everywhere!” I’d preach to anyone who’d listen. Express.js seemed like the perfect choice for our startup. Same language on frontend and backend, massive ecosystem, easy to hire developers. What could go wrong?
Everything. Absolutely everything.
My Express.js Nightmare: A Startup Story
Picture this: You’re building a real-time collaboration platform for your startup. You’ve got investors breathing down your neck, a small team, and deadlines that make your eye twitch. Express.js feels like the obvious choice — until it becomes your worst enemy.
Our first production crash happened at 2 AM on a Tuesday. A single uncaught exception in a deeply nested callback brought down our entire application. 500 users dropped instantly. I spent the rest of the night implementing process managers and prayer-based error handling.
But that was just the beginning of my Express.js horror story.
// This was our actual code (I'm not proud of it)
app.post('/api/process-data', (req, res) => {
validateUser(req.user, (err, isValid) => {
if (err) return res.status(500).send(err);
if (!isValid) return res.status(401).send('Unauthorized');
fetchUserData(req.user.id, (err, userData) => {
if (err) return res.status(500).send(err);
processBusinessLogic(userData, req.body, (err, result) => {
if (err) return res.status(500).send(err);
saveToDatabase(result, (err, saved) => {
if (err) return res.status(500).send(err);
sendNotification(req.user.id, saved, (err, notification) => {
if (err) console.log('Failed to send notification:', err);
res.json(saved);
});
});
});
});
});
});Code language: JavaScript (javascript)
Look at this monstrosity. Seven levels of nesting, error handling copy-pasted everywhere, and a callback hell that would make Dante weep. And this was considered “good” Express.js code by our team standards.
The Dependency Apocalypse
The real nightmare started when we tried to scale. Our package.json looked like a phone book—over 800 dependencies for what should have been a simple CRUD application with real-time features.
Every week, some random package would have a security vulnerability. npm audit became my daily anxiety trigger. Remember when left-pad broke the entire JavaScript ecosystem? We were affected. A 17-line package that pads strings brought down our development environment for half a day.
I spent more time managing dependencies than writing actual business logic. Webpack configuration files that looked like ancient scrolls. Build processes that required a PhD to understand. And don’t get me started on the hoisting conflicts in our monorepo.
// Our package.json dependencies (just a sample)
{
"dependencies": {
"express": "^4.18.0",
"socket.io": "^4.6.0",
"redis": "^4.5.0",
"mongoose": "^6.8.0",
"bcrypt": "^5.1.0",
"jsonwebtoken": "^9.0.0",
"express-rate-limit": "^6.7.0",
"helmet": "^6.0.0",
"cors": "^2.8.5",
"compression": "^1.7.4",
"morgan": "^1.10.0",
"dotenv": "^16.0.0",
"validator": "^13.7.0",
"multer": "^1.4.5",
"sharp": "^0.31.0",
"nodemailer": "^6.8.0",
"agenda": "^4.3.0",
"winston": "^3.8.0",
"express-validator": "^6.14.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"socket.io-redis": "^6.1.0"
// ... 780 more dependencies
}
}Code language: JSON / JSON with Comments (json)
Each of these packages brought their own dependencies. Our node_modules folder was 400MB. Installing dependencies took 10 minutes on a good day. And half of these packages were essentially solving problems that shouldn’t exist in the first place.
The Performance Wall
As we gained users, Express.js started showing its true colors. WebSocket connections would randomly drop. Memory usage would climb until the inevitable crash. Our monitoring dashboard looked like a heart rate monitor during cardiac arrest.
We tried everything: clustering, load balancers, Redis for session management, sticky sessions for Socket.io. Each solution introduced new complexity and new failure points. Our architecture diagram looked like a subway map drawn by someone having a seizure.
// Our "solution" to handle concurrency
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died`);
cluster.fork(); // Just restart and hope for the best
});
} else {
// Worker can share any TCP connection
app.listen(3000, () => {
console.log(`Worker ${process.pid} started`);
});
}Code language: JavaScript (javascript)
This felt like putting bandages on a gunshot wound. We were fighting the framework instead of building features.
The Phoenix Awakening
Then I discovered Phoenix, and everything changed.
I was skeptical at first. Another framework? Another language to learn? But our startup was burning cash on infrastructure costs, and I was burning out on 3 AM debugging sessions. I had to try something different.
The first thing that hit me was the simplicity:
# This does the same thing as that Express.js nightmare above
def create(conn, params) do
with {:ok, user} <- validate_user(conn),
{:ok, data} <- fetch_user_data(user),
{:ok, result} <- process_business_logic(data, params),
{:ok, saved} <- save_to_database(result),
{:ok, _notification} <- send_notification(user.id, saved) do
json(conn, saved)
else
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: reason})
end
endCode language: Elixir (elixir)
No callback hell. No nested conditionals. No copy-pasted error handling. Just clean, readable code that actually makes sense.
The Dependency Liberation
But the real shock came when I looked at our Phoenix application’s dependencies:
# Our entire Phoenix app dependencies
defp deps do
[
{:phoenix, "~> 1.7.0"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.6"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.18.0"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.20"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"}
]
endCode language: Elixir (elixir)
Twelve dependencies. Twelve. That’s it. And guess what? This gave us everything our Express.js application had, plus real-time features that actually worked, plus fault tolerance we could only dream of with Node.js.
No separate packages for database connections, validation, WebSockets, routing, templating, or any of the other essentials. Phoenix isn’t a minimal framework that makes you hunt for packages — it’s a complete, batteries-included solution.
Ecto: The Database Layer That Actually Works
Remember Mongoose? Remember Sequelize? Remember spending hours debugging weird ORM behaviors and fighting with database migrations?
Ecto changed everything for me. It’s not trying to hide SQL from you — it embraces it while providing powerful abstractions when you need them.
# Complex query in Ecto - readable and composable
def get_user_analytics(user_id, date_range) do
from(u in User,
join: p in assoc(u, :posts),
join: c in assoc(p, :comments),
where: u.id == ^user_id,
where: p.inserted_at >= ^date_range.start_date,
where: p.inserted_at <= ^date_range.end_date,
group_by: u.id,
select: %{
user: u,
post_count: count(p.id),
total_comments: count(c.id)
}
)
|> Repo.one()
endCode language: Elixir (elixir)
This query is complex, but it’s readable. I can understand what it’s doing without consulting documentation or running it in a debugger. Compare this to the Mongoose equivalent that would involve multiple aggregation pipelines and probably a sacrifice to the JavaScript gods.
Real-Time Without the Pain
The moment I tried Phoenix Channels, I knew Express.js was doomed for our use case:
# Real-time collaboration in Phoenix
defmodule AppWeb.DocumentChannel do
use Phoenix.Channel
def join("document:" <> document_id, _params, socket) do
{:ok, assign(socket, :document_id, document_id)}
end
def handle_in("edit", %{"content" => content, "position" => pos}, socket) do
broadcast!(socket, "edit", %{
content: content,
position: pos,
user: socket.assigns.user_id
})
{:noreply, socket}
end
endCode language: Elixir (elixir)
This simple channel could handle thousands of concurrent users editing the same document. No Redis. No sticky sessions. No clustering nightmares. Just pure, elegant real-time functionality.
Our Express.js + Socket.io setup for the same feature was over 200 lines of code, required Redis for pub/sub, and would still randomly drop connections under load.
Phoenix LiveView: The Game Changer
But LiveView? LiveView blew my mind completely.
defmodule AppWeb.DashboardLive do
use Phoenix.LiveView
def render(assigns) do
~H"""
<div>
<h1>Live Dashboard</h1>
<div>Active Users: <%= @user_count %></div>
<div>Server Load: <%= @server_load %>%</div>
<button phx-click="refresh">Refresh Now</button>
</div>
"""
end
def handle_event("refresh", _params, socket) do
{:noreply, assign(socket, :user_count, get_current_user_count())}
end
def handle_info(:update_stats, socket) do
{:noreply, assign(socket,
user_count: get_current_user_count(),
server_load: get_server_load()
)}
end
endCode language: Elixir (elixir)
This creates a real-time dashboard that updates automatically across all connected browsers. No React. No state management. No API endpoints. No WebSocket boilerplate. Just pure, server-rendered HTML that magically stays in sync.
Building the same thing in Express.js would require React, Redux, WebSocket connections, API design, and probably 10 different npm packages. The Phoenix version isn’t just simpler — it’s more reliable and performs better.
The Numbers Don’t Lie
After we migrated our core features to Phoenix:
- Memory usage dropped from 400MB to 30MB
- Response times improved from 200ms average to 15ms
- We handled 10x more concurrent users on the same hardware
- Zero crashes in six months (compared to weekly crashes with Express.js)
- Deployment time reduced from 15 minutes to 2 minutes
- Our
node_modulesfolder went from 400MB to… well, it disappeared
But the most important metric? I stopped getting 3 AM phone calls about the application being down.
The Human Cost
Here’s what no one talks about in framework comparisons: the human cost. With Express.js, I was constantly stressed. Every deployment was a gamble. Every performance issue required deep debugging through multiple layers of abstraction. I spent more time fighting the toolchain than building features.
Phoenix gave me my sanity back. Error messages that actually help. Tools that work together instead of fighting each other. A framework designed for building things instead of configuring things.
Why Phoenix Wins (And It’s Not Even Close)
Less is actually more. Phoenix proves that you don’t need 800 dependencies to build a modern web application. You need 12 really good ones that work together seamlessly.
Fault tolerance is built-in. When something goes wrong in Phoenix (and things always go wrong), it doesn’t bring down your entire application. It fails gracefully, recovers automatically, and logs what happened.
Real-time is the default. While Express.js developers cobble together real-time features from multiple packages, Phoenix applications get WebSocket support, presence tracking, and live updates out of the box.
Performance that scales. Express.js performance degrades predictably under load. Phoenix performance stays consistent even under extreme load because it was designed for massive concurrency from day one.
The Uncomfortable Truth
The Express.js ecosystem isn’t thriving because it’s good — it’s thriving because it’s familiar. JavaScript developers keep reaching for the same broken tools because learning something new feels scary.
But here’s the thing: your startup’s success isn’t worth your personal comfort zone.
If you’re building anything more complex than a simple CRUD API, if you need real-time features, if you care about performance and reliability, you owe it to yourself (and your users) to try Phoenix.

Making the Switch
I’m not saying throw away your Express.js application tomorrow. But next time you need to build a real-time feature, try it in Phoenix. Next time you’re setting up a new service, consider Phoenix instead of reaching for Express.js by default.
The learning curve is real, but it’s not as steep as you think. And the payoff? You’ll wonder why you suffered with Express.js for so long.
The great framework war isn’t about JavaScript vs Elixir. It’s about choosing tools that help you build better software instead of fighting you every step of the way.
And in that battle, Phoenix doesn’t just win — it ends the war entirely.
Have you made the switch from Express.js to Phoenix? Share your war stories in the comments below. And if you’re still suffering with Express.js, maybe it’s time to give Phoenix a try. Your 3 AM self will thank you.
Have you made the switch from Express.js to Phoenix? Share your war stories in the comments below. And if you’re still suffering with Express.js, maybe it’s time to give Phoenix a try. Your 3 AM self will thank you.

