Tutorial

How to Enforce an Upload Size Limit

Enforce upload size limits by checking Content-Length and streaming with a cap, so oversized payloads are rejected before they exhaust server memory.

On this page

  1. Introduction
  2. Check Content-Length early
  3. Stream with a cap
  4. Abort on exceed
  5. Return 413 on limit
  6. Log oversized attempts
  7. Test the cap
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of reject oversized uploads is not writing the code — it is deciding what the code is allowed to reject. A size limit setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in size limit, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

The cost of reject oversized uploads without size limit is paid in incidents, not in code. The code looks fine because the happy path works, and the happy path is what you demo. The failure path is what you operate, and the failure path is where size limit earns its keep. Instrument the rejections, alert on the spikes, and let the metric tell you when the guard is misaligned with reality.

The sections below walk through the concrete decisions: where to put the size limit check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Check Content-Length early

Check Content-Length early is the first decision because it determines what the rest of the size limit pipeline can assume. A wrong choice here turns into reject oversized uploads under load, and the invariant you thought size limit guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Check Content-Length early is the one most teams skip: drive size limit to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

const MAX = 10 * 1024 * 1024;
function limited(req) {
  if (+req.headers['content-length'] > MAX) throw 413;
  let seen = 0;
  req.on('data', c => { seen += c.length; if (seen > MAX) req.destroy(); });
}

The javascript above is the shape to ship for reject oversized uploads. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

Stream with a cap

The mechanism behind Stream with a cap is straightforward in size limit, but the consequences ripple. A wrong choice here turns into reject oversized uploads under load, and the invariant you thought size limit guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Stream with a cap is the one most teams skip: drive size limit to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Abort on exceed

Abort on exceed is where most size limit implementations leak performance. A wrong choice here turns into reject oversized uploads under load, and the invariant you thought size limit guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Abort on exceed is the one most teams skip: drive size limit to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

test('aborts on exceed', () => {
  const req = new PassThrough();
  req.headers = { 'content-length': '0' };
  limited(req);
  req.write(Buffer.alloc(11e6));
  expect(req.destroyed).toBe(true);
});

Pin the behavior with this test. A size limit setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

Return 413 on limit

The hard part of Return 413 on limit is not the happy path — it is what happens when size limit fails halfway. A wrong choice here turns into reject oversized uploads under load, and the invariant you thought size limit guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Return 413 on limit is the one most teams skip: drive size limit to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Log oversized attempts

Log oversized attempts should be observable without a debugger. A wrong choice here turns into reject oversized uploads under load, and the invariant you thought size limit guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Log oversized attempts is the one most teams skip: drive size limit to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test the cap

Test the cap is a trade, not a winner. A wrong choice here turns into reject oversized uploads under load, and the invariant you thought size limit guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test the cap is the one most teams skip: drive size limit to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in reject oversized uploads all reduce to one mistake: treating size limit as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the size limit boundary so reject oversized uploads enters unvalidated.
  • Assuming size limit is fast without measuring the tail latency under load.
  • Letting reject oversized uploads fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the size limit failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: size limit fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives size limit to the failing condition and assert the system degrades gracefully. Then reject oversized uploads is a property you maintain, not a feature you hope for.

Sources: MDN 413, Node streams

FAQ

Trust Content-Length?
Check it as a hint, but enforce on the stream. A client can lie about the header.
What status code?
413 Payload Too Large. Include the limit in the body so the client can adapt.

Related Examples