So you spent three days refactoring your app from vanilla JavaScript to React. TypeScript, proper state management, Context API, the whole nine yards. Your code reviews itself at this point. You run the production build, set up a systemd service like a real engineer, and text your friend "yo check this out" with the link.
Thirty seconds later: "bro it just says Error: Load failed"
You refresh on your own phone. Same thing. But here's the kicker—you literally just curled it two minutes ago and everything worked. The HTML loads, the JavaScript loads, the CSS loads. You can hit the backend API directly and get perfect JSON. Your cursor is blinking in the terminal and you're wondering if your phone is pranking you.
The original version still works fine by the way. Same backend, same database, same everything. You literally just rewrote the frontend. The vanilla JavaScript version on port 8000? Flawless. Your beautiful React masterpiece on port 3015? Broken.
So naturally you blame React. Then you blame Vite. Then you blame your E2E tests because those are failing too with the same error. You check the Playwright config. You check the browser console on your phone. "Failed to fetch." That's it. That's all you get. Thanks, browser. Very helpful.
Here's where you do the thing every developer does when they're stuck: you check the backend logs. You know what you find? Nothing. Zero requests. The fetch is dying before it even makes it to the server. Your phone's browser is ghosting your API.
You start getting desperate. Maybe it's DNS? You try the IP address directly. Nope. Maybe it's HTTPS? You're on HTTP for both. Maybe systemd is cursed? You restart the service. Maybe... maybe you need coffee.
Then, because you're procrastinating the real investigation, you look at the vanilla app's source code. Just curious how the old version fetches data. And there it is, line 47: const API_BASE = '/api'
Just a relative path. No HTTP, no host, no port. Just /api.
Wait. Hold on.
When you're on http://192.168.1.100:8000/contacts.html and the vanilla app fetches /api/contacts, where does that request go? The browser just appends it to the current origin. http://192.168.1.100:8000/api/contacts. Same host, same port. The page and the API are roommates.
Your React app though? You followed the best practices. Environment variables! Configuration management! VITE_API_BASE_URL=http://192.168.1.100:8000 sitting pretty in your .env file. The Twelve-Factor App would be so proud.
Except now your page loads from http://192.168.1.100:3015 and tries to fetch from http://192.168.1.100:8000.
Oh.
Oh no.
Different ports. The browser sees the page came from :3015 but the fetch is going to :8000. That's... that's a different origin. You just created a cross-origin request without realizing it.
You frantically check: does the backend have CORS headers? You curl the API endpoint with verbose flags. The response comes back clean but there's no Access-Control-Allow-Origin header. The server has no idea it's supposed to allow requests from different origins.
But wait—the backend has Socket.io working fine, and that's definitely cross-origin since it upgrades the connection. You check the server code. There it is: cors: { origin: '*' } in the Socket.io configuration. But that's only for WebSocket connections. The Express app serving your /api/contacts route? No CORS middleware. Just raw Express.
The vanilla app never needed it because everything was same-origin. You never noticed because you tested with curl, and curl doesn't care about browser security policies. Curl will fetch from anywhere, anytime, no questions asked.
Your phone's browser? Very different story. It sees a cross-origin request, sends a preflight OPTIONS to ask "hey server, you cool with requests from :3015?", the server responds with nothing special because it doesn't know what that means, and the browser goes "yeah, I'm gonna block this for your own safety."
The fetch dies before it starts. No request in the backend logs. "Failed to fetch" in the console. Your friend still waiting on that link.
This is the plot twist: nothing is actually broken. The vanilla app is correct. Your React app is also correct. The backend is correct. But you changed the architecture and forgot to tell the browser.
You've got three moves:
One: add the cors npm package to Express, literally app.use(cors()), two lines of code. The backend is already configured for CORS on WebSocket connections, just extend it to HTTP. Ship it.
Two: put nginx in front of both services. Everything runs on port 80, frontend gets /, backend gets /api. Browser thinks it's all one origin. This is what "real" production deployments look like anyway. Fancy and proper.
Three: make your production static file server proxy API requests like Vite does in dev mode. Good luck finding a static server that does this well, and also, you've just reinvented nginx but worse.
The vanilla app works because everything's together. Your React app fails because you split them up and didn't add the middleware. Same machine, different ports, different problems.
Your friend is still waiting for that link.
You go with option one because you're not trying to over-engineer this. Two minutes later you've added const cors = require('cors') and app.use(cors()) to the backend. The package was already installed for Socket.io anyway. You restart the service. You rebuild the frontend. You restart that service too.
You curl the API again. There it is: Access-Control-Allow-Origin: * in the response headers. Beautiful. You run your E2E tests. They pass. Repository navigation works. The backend returns data. The frontend fetches it. Everything matches.
You text your friend: "try now."
Thirty seconds later: "still broken lol"
You stare at your phone. You refresh. "Error: Load failed."
But... the tests pass. You literally just watched Playwright open a browser, navigate to your frontend, fetch from the backend, and verify the data. The test explicitly checks that the backend API returns repos and the frontend displays them. It passed. The logs show ten repositories fetched successfully.
You curl your frontend: HTML loads. You curl the JavaScript bundle: it's there, and you can see http://192.168.1.100:8000 baked right into it. You curl the backend: JSON comes back with CORS headers. Everything works in isolation.
So you write a new test. A smoke test. Dead simple. Fetch repos from backend (works). Load frontend in a real browser (works). Wait for repository list to appear (works). Verify the data matches (works). Test passes.
Your phone: "Error: Load failed."
You check the server timestamps. Frontend service restarted 13 minutes ago. Build is from 13 minutes ago. They match. You're serving the latest code. The systemd service is running the production build. The same build that the passing tests are hitting.
Then you remember: the original React refactoring worked. You literally tested it. It loaded the repo list perfectly. That was before you added the environment variables. That version used relative URLs: fetch('/api/contacts'). And it worked because Vite's dev server had a proxy configured.
But you're running production now. Not the dev server. The serve package serving your static files on port 3015. No proxy. Relative URLs would fail because there's no /api endpoint on :3015.
So you added VITE_API_BASE_URL to make the absolute URL work in production. Which meant you needed CORS. Which you added. Which the tests confirm works.
But your phone still says no.
You're about to throw your laptop out the window when you notice something. The tests pass consistently. Your phone fails consistently. But you also remember earlier when the tests failed... they failed because the repository list wouldn't load. "Failed to fetch" just like your phone.
That was before the CORS fix.
After the CORS fix, you rebuilt, restarted, and the tests passed.
Your phone is still seeing the old error. The error from before CORS. But the new build has CORS working. The tests prove it.
Unless...
What if your phone is cached? No, you tried hard refresh. What if it's a network thing? No, you're on the same network. What if the build is actually old? No, you just checked the timestamps.
Wait.
You run the smoke test again just to be sure. Backend returns 10 repos. Frontend fetches 10 repos. CORS header present. Test passes. Verbose output shows every step working.
Your phone: "Error: Load failed."
You've verified the backend works. You've verified the frontend works. You've verified CORS works. You've verified the tests hit the production systemd service. You've verified the build is fresh.
So why does your phone hate you?
Sometimes the computer is right and you're looking at the wrong thing. Sometimes your tests are lying. Sometimes the error message means something completely different than what it says.
You sit there, staring at passing tests and a broken phone, and then you mention something in passing. Just conversation. "The phone and dev environment aren't on the same network, by the way."
Wait, what?
"Yeah, we're building on an EC2 instance, the phone is hitting it from home WiFi using the EC2 IP."
You look at your .env file. VITE_API_BASE_URL=http://172.31.24.23:8000
Oh no.
Oh no no no.
172.31.x.x is a private IP. AWS internal networking. Your EC2 instance can reach it because from inside EC2, that's localhost. Your tests can reach it because Playwright runs on the EC2 instance.
Your phone, sitting on a couch three thousand miles away on home WiFi, cannot reach 172.31.24.23 because that IP doesn't exist outside of Amazon's private network.
The page loads fine because you're hitting the EC2's public IP to get the HTML. But then the JavaScript runs and tries to fetch from http://172.31.24.23:8000, and your phone's browser goes "uh, what? I don't know how to route to that" and gives up.
The tests pass because they're running in the same network where private IPs work. Your phone fails because it's in reality, where private IPs are unreachable.
You check the EC2's public IP. 35.160.208.215. You update .env:
VITE_API_BASE_URL=http://35.160.208.215:8000
You rebuild. You restart. You hold your breath.
You refresh your phone.
The repos load.
All ten of them. Right there. "web-react", "web", "yap"... the whole list. Your friend texts back: "oh sick it works now"
The bug wasn't CORS. I mean, it was, you did need to add that. But the reason your phone failed wasn't the CORS issue. It was that your JavaScript was trying to fetch from an IP address that doesn't exist on the public internet.
Your tests couldn't catch this because tests run in the same environment as the code. They see the same network. The same localhost. The same private IPs. Everything that works for the code works for the tests.
The real users? They're out there in the actual world, on actual networks, where 172.31.24.23 means nothing.
This is the thing they don't tell you about "production-grade E2E tests." You can have perfect selectors, content-based waits, verbose logging, smoke tests that verify end-to-end data flow... and still miss the bug that breaks your app for actual users.
Because your tests and your code are both lying to you, together, as a team.
The vanilla app worked because it used relative URLs. No hardcoded IPs, no environment variables pointing to private networks. Just /api/contacts, which the browser resolves to wherever the page came from.
You fixed it by switching to absolute URLs for production, which required CORS, which worked perfectly in your tests, which all passed, which told you nothing about the fact that you'd configured your app to make requests to an IP address that doesn't exist in your users' universe.
Sometimes the bug isn't in your code. Sometimes it's in the difference between where your code runs and where your users live.
Your friend is happy. Your tests are passing. Your phone works. You learned something about private vs public IPs, about test environments vs production, about the limits of even the best E2E tests.
And somewhere, another developer is probably doing the exact same thing right now, watching their tests pass while their users report bugs, wondering what they're missing.
The answer is usually: context. The thing your tests can't have because they run in the same place as your code.
Ship it.