Bug yang tidak melempar error
Form kontak berhenti bekerja. Bukan error 500, bukan stack trace. Cuma redirect balik ke halaman yang sama dengan pesan kosong. Log aplikasi bersih. Query MySQL tidak pernah muncul di log slow query.
Gejalanya begini:
POST /services/contact 302 4.112 ms - 58
POST /services/contact 302 3.980 ms - 58
Status 302, response cepat, tidak ada yang mencurigakan. Baru setelah console.log(req.body) dipasang di handler, penyebabnya keluar:
undefined
req.body undefined. Bukan {} — undefined. Artinya body parser tidak pernah menyentuh request itu. Penyebabnya satu baris di app.js: router /services dimount di baris 40, sedangkan express.urlencoded() dipasang di baris 58.
Express tidak protes. Tidak ada warning. Middleware yang didaftarkan setelah router yang menangani request tidak pernah dieksekusi untuk request tersebut, dan Express menganggap itu wajar. Inilah kelas bug yang paling mahal waktunya: kode terlihat benar, semua modul terpasang, tapi urutannya salah.
Artikel ini memetakan enam kesalahan urutan yang paling sering muncul di aplikasi Express 4, cara mendiagnosisnya, dan urutan kanonik yang bisa kamu pakai sebagai template.
Model mental: stack, bukan konfigurasi
Setiap app.use() dan app.get() mendorong satu layer ke dalam array. Saat request masuk, Express menelusuri array itu dari indeks 0 ke atas. Layer yang path-nya cocok akan dijalankan. Request berhenti bergerak begitu ada layer yang mengirim response dan tidak memanggil next().
Bukti paling gampang — pasang tiga middleware dan lihat outputnya.
const express = require('express');
const app = express();
app.use((req, res, next) => { console.log('layer 1'); next(); });
app.get('/ping', (req, res) => {
console.log('handler /ping');
res.send('pong');
});
app.use((req, res, next) => { console.log('layer 2'); next(); });
app.listen(3000);
Hit GET /ping, outputnya:
layer 1
handler /ping
layer 2 tidak pernah tercetak. Handler /ping mengirim response tanpa next(), jadi perjalanan berhenti di situ. Kalau kamu hit GET /lain, barulah muncul:
layer 1
layer 2
Ingat kalimat ini: middleware yang didaftarkan setelah route yang menangani request adalah kode mati untuk request itu. Hampir semua bug urutan adalah variasi dari kalimat tersebut.
Yang membuatnya sulit dilacak adalah sifatnya yang parsial. Middleware yang salah posisi tetap jalan untuk sebagian request — yaitu request yang tidak cocok dengan route mana pun di atasnya. Jadi kamu bisa punya aplikasi di mana session bekerja normal di halaman publik tapi selalu kosong di panel admin, dan itu terlihat seperti bug otentikasi, bukan bug urutan. Kamu akan mencari di tempat yang salah.
Body parser setelah route: req.body undefined
Ini kasus pembuka tadi. Versi salah:
// SALAH — router dimount sebelum parser
app.use('/services', require('./routes/services/contactService'));
app.use('/admin', require('./routes/admin'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
Handler di contactService.js:
router.post('/contact', (req, res) => {
const { nama, email, pesan } = req.body; // TypeError menunggu
db.query('INSERT INTO contact (nama, email, pesan) VALUES (?, ?, ?)',
[nama, email, pesan], (err) => { /* ... */ });
});
Kalau kamu destructure langsung dari undefined, Node melempar TypeError: Cannot destructure property 'nama' of 'req.body' as it is undefined. Kalau kamu pakai req.body.nama di dalam try/catch yang menelan error, kamu dapat redirect diam seperti di awal artikel.
Versi benar — parser selalu di atas router:
// BENAR
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use('/services', require('./routes/services/contactService'));
app.use('/admin', require('./routes/admin'));
Hal yang sama berlaku untuk express-session. Kalau session() dipasang setelah router admin, req.session.loggedin akan undefined, dan middleware requireAuth kamu akan menolak user yang sebenarnya sudah login. Gejalanya: login sukses, lalu langsung terlempar ke halaman login lagi.
multer: pengecualian yang perlu kamu tahu
express.urlencoded() tidak memproses multipart/form-data. Kalau form upload kamu pakai enctype="multipart/form-data", req.body akan kosong walau parser sudah di posisi benar.
// SALAH — urlencoded tidak akan mengisi req.body untuk multipart
router.post('/upload', (req, res) => {
console.log(req.body); // {}
console.log(req.file); // undefined
});
// BENAR — multer dipasang per-route, bukan global
const multer = require('multer');
const upload = multer({ dest: 'public/uploads/' });
router.post('/upload', upload.single('gambar'), (req, res) => {
console.log(req.file.filename); // 1721300000000.png
console.log(req.body.judul); // field teks lain ikut terisi
});
Jangan pasang multer secara global dengan app.use(upload.any()). Itu membuat setiap request non-multipart tetap melewati parser file, dan membuka permukaan serangan yang tidak perlu.
compression setelah static: aset tidak terkompres
compression bekerja dengan membungkus res.write dan res.end. Pembungkusan itu harus terjadi sebelum middleware yang menulis response. express.static menulis response. Jadi kalau compression() datang belakangan, file CSS dan JS kamu terkirim mentah.
// SALAH
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '30d' }));
app.use(compression());
Cek dengan curl:
curl -sI -H 'Accept-Encoding: gzip' http://localhost:3000/stylesheets/index.min.css
Output versi salah:
HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Content-Length: 184320
Cache-Control: public, max-age=2592000
Tidak ada header Content-Encoding. 180 KB terkirim apa adanya.
// BENAR — compression paling atas
app.use(compression());
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '30d' }));
Output versi benar:
HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Content-Encoding: gzip
Vary: Accept-Encoding
Transfer-Encoding: chunked
184 KB turun ke sekitar 24 KB. Perhatikan Content-Length hilang dan diganti Transfer-Encoding: chunked — itu tanda kompresi benar-benar aktif, bukan cuma header kosmetik.
static sebelum atau sesudah router?
Aturan praktisnya: static di atas router aplikasi, tapi di bawah compression.
Alasannya performa. File statis adalah mayoritas request di situs marketing. Kalau express.static dipasang paling akhir, setiap request /images/hero.webp harus melewati session store, body parser, dan seluruh matcher router sebelum sampai ke file handler.
// SALAH — setiap aset menembus seluruh stack dulu
app.use(session({ /* ... */ }));
app.use('/', require('./routes/index'));
app.use('/blog', require('./routes/blog'));
app.use(express.static('public'));
Efek sampingnya bukan cuma lambat. express-session akan membuat sesi baru untuk setiap request gambar, dan in-memory store kamu membengkak. Untuk halaman dengan 40 aset, satu kunjungan bisa menghasilkan puluhan entri sesi sampah.
// BENAR
app.use(compression());
app.use(express.static('public', { maxAge: '30d' }));
app.use('/images', express.static('public/images', { maxAge: '1y' }));
app.use(session({ /* ... */ }));
app.use('/', require('./routes/index'));
Catatan: express.static memanggil next() bila file tidak ditemukan, jadi menaruhnya di atas tidak akan memblokir route dinamis kamu. Kecuali kalau ada nama file yang bentrok dengan path route — misal folder public/blog/ dan router /blog. Kalau begitu, static akan menang. Pisahkan namespace-nya.
Catch-all 404 yang menelan route
Ini jebakan paling sering di file besar. app.use() tanpa path cocok dengan semua request.
// SALAH
app.use('/', require('./routes/index'));
app.use('/blog', require('./routes/blog'));
app.use((req, res) => {
res.status(404).render('404');
});
// ditambahkan belakangan oleh kolega
app.use('/studycase', require('./routes/studycase'));
Router /studycase tidak akan pernah jalan. Setiap hit menghasilkan halaman 404, padahal file route-nya ada dan tidak ada typo. Orang bisa habis satu jam mengecek nama file dan module.exports.
// BENAR — catch-all selalu paling akhir, setelah semua router
app.use('/', require('./routes/index'));
app.use('/blog', require('./routes/blog'));
app.use('/studycase', require('./routes/studycase'));
app.use((req, res) => {
res.status(404).render('404', { title: 'Halaman tidak ditemukan' });
});
Cara mencegahnya secara struktural: kumpulkan semua mount router dalam satu blok berlabel, dan taruh 404 + error handler di file terpisah yang dipanggil paling bawah. Komentar // JANGAN TAMBAH ROUTER DI BAWAH BARIS INI itu murah dan efektif.
Error handler 4-argumen yang tidak pernah dipanggil
Express membedakan error handler dari middleware biasa hanya lewat jumlah parameter fungsi. Empat parameter (err, req, res, next) berarti error handler. Tiga parameter berarti middleware biasa.
Dua kesalahan umum:
// SALAH 1 — diletakkan sebelum route
app.use((err, req, res, next) => {
res.status(500).render('error', { message: err.message });
});
app.use('/blog', require('./routes/blog'));
Error yang terjadi di /blog tidak akan tertangkap. Express mencari error handler setelah posisi layer yang melempar error, tidak pernah ke belakang.
// SALAH 2 — parameter next dihapus karena "tidak dipakai"
app.use((err, req, res) => {
res.status(500).render('error');
});
Fungsi ini punya tiga parameter. Express memperlakukannya sebagai middleware biasa, dan err akan berisi objek req. Kamu akan dapat error aneh seperti Cannot read properties of undefined (reading 'render'). Jangan pernah hapus next dari signature error handler, walau linter kamu mengeluh.
// BENAR — paling akhir, empat parameter utuh
app.use('/blog', require('./routes/blog'));
app.use((req, res) => res.status(404).render('404'));
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
console.error(err.stack);
res.status(err.status || 500).render('error', {
message: err.message,
error: process.env.NODE_ENV === 'development' ? err : {}
});
});
Middleware async yang lupa next(err): request menggantung
Express 4 tidak menangkap rejected promise. Kalau kamu pakai async di handler dan ada yang throw, error itu hilang dan request tidak pernah dijawab. Browser berputar sampai timeout.
// SALAH — throw di dalam async hilang ditelan
router.get('/:slug', async (req, res) => {
const [rows] = await db.promise().query(
'SELECT * FROM blogs WHERE slug = ?', [req.params.slug]
);
res.render('pages/blog-detail', { blog: rows[0] });
});
Kalau koneksi MySQL putus, await reject, Express tidak tahu apa-apa. Log morgan akan menunjukkan gejala khasnya — request tanpa baris response:
GET /blog/tips-seo - - ms - -
Dua tanda hubung di kolom status dan durasi. Itu artinya response tidak pernah selesai.
// BENAR — teruskan error ke next()
router.get('/:slug', async (req, res, next) => {
try {
const [rows] = await db.promise().query(
'SELECT * FROM blogs WHERE slug = ?', [req.params.slug]
);
if (!rows.length) {
const err = new Error('Artikel tidak ditemukan');
err.status = 404;
return next(err);
}
res.render('pages/blog-detail', { blog: rows[0] });
} catch (err) {
next(err);
}
});
Kalau kamu punya banyak route async, bungkus sekali dengan helper agar tidak perlu menulis try/catch berulang di setiap handler:
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
router.get('/:slug', wrap(async (req, res) => { /* ... */ }));
Pola yang sama berlaku untuk callback gaya lama. Kalau kamu masih memakai db.query(sql, params, cb) lalu mengabaikan argumen err di dalam callback, gejalanya persis sama: request menggantung tanpa jejak apa pun di log. Argumen err yang tidak diteruskan ke next adalah error yang hilang.
Cara mendiagnosis urutan
1. Logger urutan bernomor
Sisipkan probe di beberapa titik app.js untuk melihat apa yang benar-benar jalan.
let step = 0;
const probe = (label) => (req, res, next) => {
console.log(`[${++step}] ${label} — ${req.method} ${req.url} body=${typeof req.body}`);
next();
};
app.use(probe('sebelum parser'));
app.use(express.urlencoded({ extended: false }));
app.use(probe('sesudah parser'));
Output untuk POST /services/contact:
[1] sebelum parser — POST /services/contact body=undefined
[2] sesudah parser — POST /services/contact body=object
Kalau probe kedua tidak muncul, ada layer di atasnya yang menghentikan request.
2. Inspeksi app._router.stack
Ini cara paling cepat melihat urutan sebenarnya tanpa membaca ulang seluruh file. Taruh di bin/www setelah app dimuat.
app._router.stack.forEach((layer, i) => {
const nama = layer.name;
const path = layer.route ? layer.route.path
: (layer.regexp && layer.regexp.source);
const isErrorHandler = layer.handle.length === 4;
console.log(`${String(i).padStart(2)} ${nama.padEnd(22)} ${isErrorHandler ? '[ERR] ' : ''}${path}`);
});
Output tipikal aplikasi sehat:
0 query /^\/?$/
1 expressInit /^\/?$/
2 compression /^\/?$/
3 logger /^\/?$/
4 jsonParser /^\/?$/
5 urlencodedParser /^\/?$/
6 serveStatic /^\/public\/?/
7 session /^\/?$/
8 router /^\/blog\/?/
9 router /^\/services\/?/
10 <anonymous> /^\/?$/
11 <anonymous> [ERR] /^\/?$/
Baca dari atas. Kalau urlencodedParser muncul di indeks lebih besar dari router mana pun, kamu sudah menemukan bug-nya. Kolom [ERR] mengonfirmasi error handler benar-benar terdeteksi 4-argumen — kalau labelnya tidak muncul, signature kamu salah.
Catatan: app._router adalah API internal. Aman untuk debugging lokal, jangan dipakai di jalur produksi. Di Express 5 namanya berubah jadi app.router.
3. morgan untuk membaca gejala
Pasang morgan tepat di bawah compression, sebelum semua parser dan router, supaya ia mencatat setiap request termasuk yang gagal di tengah.
app.use(compression());
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
Cara membaca outputnya:
| Pola log | Kemungkinan penyebab |
|---|---|
POST ... 302 - 3ms berulang, DB kosong | body parser di bawah router, req.body undefined |
GET ... - - ms - - | handler async lupa next(err), request menggantung |
GET /studycase 404 padahal route ada | catch-all 404 dimount sebelum router itu |
GET /*.css 200 tanpa Content-Encoding | compression di bawah static |
| Login sukses lalu balik ke login | session di bawah router admin |
500 tapi halaman error putih | error handler bukan 4-argumen |
Urutan kanonik yang direkomendasikan
Pakai ini sebagai kerangka app.js. Urutannya bukan selera — tiap posisi punya alasan teknis di atas.
const app = express();
// 1. View engine & konfigurasi (tidak mengeksekusi request)
app.set('view engine', 'hbs');
// 2. Compression — harus membungkus semua penulis response
app.use(compression());
// 3. Logging — sedini mungkin agar request gagal tetap tercatat
app.use(morgan('dev'));
// 4. Aset statis — cabut lebih awal, jangan bebani session store
app.use('/public', express.static('public', { maxAge: '30d' }));
app.use('/images', express.static('public/images', { maxAge: '1y' }));
// 5. Body parser — sebelum apa pun yang membaca req.body
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// 6. Session — sesudah cookieParser, sebelum route yang butuh auth
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
// 7. Router aplikasi
app.use('/', require('./routes/index'));
app.use('/blog', require('./routes/blog'));
app.use('/admin', require('./routes/admin'));
app.use('/services', require('./routes/services/contactService'));
// JANGAN TAMBAH ROUTER DI BAWAH BARIS INI
// 8. Catch-all 404
app.use((req, res) => res.status(404).render('404'));
// 9. Error handler — wajib 4 argumen, wajib terakhir
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
console.error(err.stack);
res.status(err.status || 500).render('error', { message: err.message });
});
Multer sengaja tidak ada di daftar ini. Pasang per-route saja.
Checklist audit
Jalankan ini pada app.js mana pun sebelum deploy:
-
compression()berada di atas semuaexpress.staticdan router. -
express.json()danexpress.urlencoded()berada di atas semuaapp.use('/path', router). -
session()berada di bawahcookieParser()dan di atas router yang membacareq.session. -
express.staticdi atas router aplikasi, dan tidak ada folder publik yang namanya bentrok dengan path route. - Catch-all 404 adalah middleware kedua dari bawah — tidak ada
app.userouter setelahnya. - Error handler punya persis empat parameter, termasuk
nextyang tidak dipakai. - Setiap handler
asyncmembungkus body-nya dengan try/catch +next(err), atau dibungkus helperwrap(). -
multerdipasang per-route, bukan global. - Verifikasi kompresi:
curl -sI -H 'Accept-Encoding: gzip' <url-css>mengembalikanContent-Encoding: gzip. - Verifikasi urutan: dump
app._router.stacksekali, baca dari indeks 0.
Dua langkah terakhir memakan waktu kurang dari satu menit dan menangkap mayoritas bug di artikel ini. Lakukan sekarang, sebelum ada yang melapor form kontaknya diam.



