Count Working Days in JavaScript With UK Bank Holidays
Count and add working days in JavaScript with UTC dates, skip weekends and UK bank holidays from gov.uk's JSON, and avoid the clocks-change bug.
To send a POST request with a JSON body using JavaScript's fetch(), pass an options object with method: "POST", a "Content-Type": "application/json" header, and body: JSON.stringify(data). Then check response.ok and read the reply with await response.json(). Below is a working fetch POST JSON example, the output from running it against a real test server, the two mistakes that cause most "my server gets an empty body" bugs, and a reusable helper with error handling and a timeout.
I use this pattern on my own projects whenever a form talks to an API route, such as the contact form and the quiz generator on my portfolio. To make sure every example here actually works, I wrote a tiny Node 20 server that echoes back what it receives and complains loudly when something is wrong. Node 20 has fetch() built in, and it behaves the same as in the browser for everything shown here.
// post.mjs: the basic fetch POST with a JSON body
const response = await fetch("http://localhost:3000/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "aisha@example.co.uk", message: "Hello!" }),
});
console.log(response.status);
const data = await response.json();
console.log(data);
Running it against the test server printed:
201
{
id: 42,
received: { email: 'aisha@example.co.uk', message: 'Hello!' }
}
The 201 is the HTTP status code ("Created"), and the server sent back the same object, so it arrived intact. Each part of the options object has a job:
method: "POST": without it, fetch() sends a GET, which can't have a body.headers: Content-Type: application/json tells the server how to read the body.body: JSON.stringify(...): turns your JavaScript object into a JSON string, because a request body has to be text (or binary), not an object.The two awaits are there because fetch() first resolves when the headers arrive, and reading the body is a second asynchronous step. If await is new to you, start with my JavaScript async/await tutorial for beginners.
Here's the server, so you can see exactly what it checks. It rejects anything that isn't labelled as JSON, anything that doesn't parse, and messages missing a field:
// server.mjs: a tiny JSON API to test fetch() against (Node 20, no packages)
import http from "node:http";
const server = http.createServer(async (req, res) => {
let raw = "";
for await (const chunk of req) raw += chunk;
const type = req.headers["content-type"] ?? "(none)";
const send = (status, data) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
};
if (req.url === "/slow") return setTimeout(() => send(200, { ok: true }), 3000);
if (req.method !== "POST") return send(405, { error: "Use POST" });
if (!type.startsWith("application/json")) {
return send(415, { error: `Expected JSON, got ${type}`, received: raw });
}
let body;
try { body = JSON.parse(raw); } catch {
return send(400, { error: "Body is not valid JSON", received: raw });
}
if (!body.email || !body.message) {
return send(422, { error: "email and message are required" });
}
send(201, { id: 42, received: body });
});
server.listen(3000, () => console.log("API on http://localhost:3000"));
Save it as server.mjs and run node server.mjs, then run the examples in a second terminal.
Almost every "fetch POST not working" question I've seen comes down to one of these. I ran both on purpose:
// mistakes.mjs: the two most common fetch POST mistakes
const url = "http://localhost:3000/api/messages";
const payload = { email: "aisha@example.co.uk", message: "Hello!" };
// 1. Forgetting JSON.stringify()
let res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
});
console.log(res.status, await res.json());
// 2. Forgetting the Content-Type header
res = await fetch(url, { method: "POST", body: JSON.stringify(payload) });
console.log(res.status, await res.json());
// Output:
// 400 { error: 'Body is not valid JSON', received: '[object Object]' }
// 415 {
// error: 'Expected JSON, got text/plain;charset=UTF-8',
// received: '{"email":"aisha@example.co.uk","message":"Hello!"}'
// }
When you pass a plain object as body, fetch() converts it to a string the only way it knows: "[object Object]". Your data never leaves the browser. The server got exactly that text and answered 400 because it isn't valid JSON.
With a string body and no header, fetch() labels it text/plain;charset=UTF-8. The JSON arrived perfectly, but the server refused it with 415 Unsupported Media Type. Some frameworks, such as Express with express.json(), simply skip parsing in this case and leave the body empty, which is even more confusing.
This catches out a lot of beginners. fetch() only rejects when the request can't be made at all, for example when you're offline or the server doesn't exist. A 404, 422 or 500 is a perfectly good response as far as fetch() is concerned. You have to check response.ok (true for any status from 200 to 299) yourself. That's why I wrap it in a small helper:
// postJSON.mjs: a reusable helper with error handling and a timeout
export async function postJSON(url, data, { timeoutMs = 8000 } = {}) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(data),
signal: AbortSignal.timeout(timeoutMs),
});
const text = await response.text();
const body = text ? JSON.parse(text) : null;
if (!response.ok) {
const error = new Error(body?.error ?? `HTTP ${response.status}`);
error.status = response.status;
throw error;
}
return body;
}
A few choices are deliberate here:
204 No Content, have no body, and calling response.json() on an empty body throws. If your server can send an HTML error page, wrap the JSON.parse() in a try as well.Error with the server's message and status, so the calling code can show "email and message are required" instead of a vague "something went wrong".AbortSignal.timeout() cancels the request if the server hangs. Without it, fetch() can wait for a very long time.Here's the helper used for a good request, a request missing a field, and a request to a slow endpoint that takes three seconds to reply:
import { postJSON } from "./postJSON.mjs";
const api = "http://localhost:3000";
try {
const saved = await postJSON(`${api}/api/messages`, { email: "tom@example.com", message: "Hi" });
console.log("saved:", saved);
await postJSON(`${api}/api/messages`, { email: "tom@example.com" });
} catch (err) {
console.log(`failed (${err.status}): ${err.message}`);
}
try {
await postJSON(`${api}/slow`, {}, { timeoutMs: 1000 });
} catch (err) {
console.log(`failed: ${err.name}: ${err.message}`);
}
// Output:
// saved: { id: 42, received: { email: 'tom@example.com', message: 'Hi' } }
// failed (422): email and message are required
// failed: TimeoutError: The operation was aborted due to timeout
The 422 came back as a readable error, and the slow request was cancelled after one second with a TimeoutError. Remember that the browser checks are only for the user's convenience. The server has to validate the data again, which I cover for PHP in my PHP form validation and sanitisation guide.
If the data comes from an HTML form, you don't have to read each field by hand. new FormData(form) collects every named field, and Object.fromEntries() turns it into a normal object that you can stringify:
// formdata.mjs: turning form fields into a JSON body
// In the browser you'd write: const fields = new FormData(formElement);
const fields = new FormData();
fields.append("email", "rahim@example.com");
fields.append("message", "Sent from a form");
const data = Object.fromEntries(fields);
console.log(JSON.stringify(data));
const res = await fetch("http://localhost:3000/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
console.log(res.status, (await res.json()).received);
// Output:
// {"email":"rahim@example.com","message":"Sent from a form"}
// 201 { email: 'rahim@example.com', message: 'Sent from a form' }
In the browser you'd do this inside a submit listener and call event.preventDefault() first, so the page doesn't reload. One limitation: if a form has several fields with the same name, such as checkboxes, Object.fromEntries() keeps only the last value. Use fields.getAll("name") for those.
You'll still see this style in older tutorials and code bases. It does exactly the same thing:
// then.mjs: the same request with .then() instead of await
fetch("http://localhost:3000/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "zoe@example.com", message: "Using .then()" }),
})
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((data) => console.log("id:", data.id))
.catch((error) => console.error("Request failed:", error.message));
// Output:
// id: 42
I prefer async/await because the error handling reads top to bottom, but both are fine. Just make sure there's a .catch() at the end, or a failed request will become an "unhandled promise rejection".
If your page and the API are on different domains, the browser first sends a "preflight" OPTIONS request, because Content-Type: application/json isn't one of the simple types allowed without permission. The API must reply with the right Access-Control-Allow-* headers, or the browser blocks the response. That has to be fixed on the server; nothing in your fetch() options can get around it. MDN's Using the Fetch API guide covers this and the other options in detail.
Set method: "POST", add the header "Content-Type": "application/json", and pass JSON.stringify(yourObject) as the body. The server then parses the string back into an object.
Usually the Content-Type header is missing, so the server doesn't parse the body as JSON, or the object was passed without JSON.stringify() and arrived as "[object Object]". Both are shown above.
No. It only rejects on network failures, timeouts and aborts. Check response.ok or response.status and throw your own error when the request didn't succeed.
It's optional. Accept: application/json tells the server you'd like JSON back. Most JSON APIs reply with JSON regardless, but some use it to choose between JSON and HTML.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Count and add working days in JavaScript with UTC dates, skip weekends and UK bank holidays from gov.uk's JSON, and avoid the clocks-change bug.
Work out a First, 2:1 or 2:2 from module marks: credit-weighted averages, 30:70 or 1:2 year weightings, rounding at 69.5 and the mark you need.
How UK student loan repayments are worked out in 2026/27: per-period thresholds, 9% over, rounding down, two plans at once and a floating-point trap.