Exit code 0 is not evidence
Our Swiss grocery price comparison had five products in its database. Not five thousand. Five, and one of them was called "Tomato beef mix", because they were fixtures somebody seeded during development.
Meanwhile the scrapers ran on schedule and exited 0 every time.
The scrapers write to Postgres as they go, and building that connection is
wrapped in a try/except — a database being briefly unreachable should not
throw away a whole scrape run. Reasonable. But the connection string pointed at
a Supabase project that had been deleted months earlier, so every run logged one
line:
Database connection failed … Continuing without database functionality
then kept scraping happily to CSV and returned success. Nothing watched that line. The exit code was 0. The CSVs got fresher. The website served five tomatoes.
Pointing it at the live database turned five rows into 21,736 real products across Migros, Coop, Lidl, Aldi and Denner. The scraping had worked the whole time. Only the part that mattered was missing.
The lesson is narrower than "add monitoring"
An exit code tells you whether a process crashed. It does not tell you whether
the process did its job, and for any program whose purpose is a side effect
those are different questions. If the point of the job is that rows exist
afterwards, the check is select count(*), not $?.
The same day, we found that checkout had never worked either.
Stripe was rejecting every session. automatic_tax was enabled, but a Customer
created from an email address alone has no address, and automatic tax needs one.
The fix is a single parameter — customer_update={"address": "auto"}.
What is interesting is how it stayed hidden. It failed identically in test mode and live mode, so nobody could have caught it by "testing with a test card". And the frontend turned the resulting 502 into a polite "could not start checkout", which reads like a transient glitch rather than a permanent one.
Both bugs have the same shape. A failure was converted into something that looked like a normal state — a warning line in a log, a friendly error message in a UI — and then nobody checked the actual outcome. Walking the funnel end to end against production, registering an account and posting to the checkout endpoint, found both in about ten minutes. Reading the code had not found either in months.
Next.js 16 on Cloudflare Workers
The frontend runs on Workers through @opennextjs/cloudflare. Three things
worth knowing before you try it:
The version gap is real. The adapter supports >=15.5.24 <16 || >=16.3.3.
We were on 16.0.10, which is precisely in the hole.
**pg needs help.** It reaches for pg-cloudflare on workerd, but that import
sits behind the package's workerd export condition, so Next's file tracer
copies only the default stub and the Worker cannot resolve the real TCP socket
implementation at runtime. outputFileTracingIncludes forces the real build in.
The free plan is not an option, and size is not the reason. Our Worker is
about 3.2 MiB gzipped against a 3 MiB free cap, which looks like something you
could trim your way out of. You cannot. wrangler tail showed real page renders
using 16, 31, 33 and 35 ms of CPU against a 10 ms free limit. Every one is
over, by 1.6x to 3.5x. A zero-byte Worker would not fit. If you are putting
Next.js SSR on Workers, you are on the paid plan — budget the $5 and stop
optimising.
One more, because it is the same lesson
A sitemap listing 10,204 product URLs took 1,474 seconds to serve and then returned 502. Google abandons a sitemap fetch in seconds, so Search Console had been answering "URL is unknown to Google, crawled: never" for a domain whose sitemap it had supposedly held since June.
The cause: the route re-ran 50 sequential backend calls on every request. A
comment above the fetch loop said it "runs once, at build time". That was the
intent. Nothing enforced it, and Next treats an uncached fetch as dynamic.
Prerendering it took the response to 0.11 seconds. The comment had been true about somebody's plan and false about the program for as long as the file had existed, which is the most expensive kind of comment there is.