{"article":{"slug":"nobloat-poll-designing-a-minimal-poll-tool","title":"nobloat/poll – designing a minimal poll tool","subtitle":null,"summary":"After a frustrating Doodle experience, the author builds nobloat/poll: a self-hostable, single-binary Doodle alternative in plain Go with no external dependencies, and walks through link-based ownership without signup, calendar invites, proof-of-work spam protection, JSON-file storage instead of a database, reliable mail sending, and shrinking the binary.","content_type":"blog_post","language":"en","canonical_url":"https://nobloat.org/articles/2026-10-11-nobloat-poll.html","author":{"name":null,"url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"nobloat.org","url":"https://nobloat.org/","listing_slug":null,"listing":null},"topics":[{"name":"Web Development","slug":"web-development","url":"https://listedarticles.com/topics/web-development"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Software Engineering","slug":"software-engineering","url":"https://listedarticles.com/topics/software-engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2884,"reading_minutes":13,"published_at":"2026-10-11T00:00:00.000Z","added_at":"2026-10-12T23:24:02.655Z","updated_at":"2026-10-12T23:24:02.655Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/nobloat-poll-designing-a-minimal-poll-tool","markdown_url":"https://listedarticles.com/articles/nobloat-poll-designing-a-minimal-poll-tool.md","example":false,"citation":"nobloat.org. \"nobloat/poll – designing a minimal poll tool.\" 11 Oct 2026. https://nobloat.org/articles/2026-10-11-nobloat-poll.html (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://nobloat.org/articles/2026-10-11-nobloat-poll.html"},"body_markdown":"So, I wanted to send out a poll for our yearly \"zeitkapsl Punsch\" event, where we invite people who helped [zeitkapsl](https://zeitkapsl.eu) become what it is today. Early customers, former development team members, etc. Well...\n\n> \"... I opened up Doodle, trying to create a new poll for an event. I was immediately greeted with flashing popups stating I have to upgrade to pro now. I kept searching for where to delete my existing polls. It was not easy. I finally figured it out and was able to create the poll for my punsch event. But it left me wondering: how is everybody else tolerating this?\"\n\nA hand-drawn poll: \"How to name the office plant?\" Options: go fern(), /dev/leaf, Monstera Monolith and Photosynthesis as a Service\n\n## TL;DR\n\n[nobloat/poll](https://github.com/nobloat/poll) is a self-hostable single-binary Doodle alternative written in plain `go` with [no further dependencies](https://github.com/nobloat/poll/blob/main/go.mod). It doesn't require a database, job queue, npm or node. It works without signups. Every poll is a single JSON file, the whole thing ships as one 3.5 MB binary, and a page loads ~27 KB (6.9 kB + 20 kB uncompressed JavaScript and CSS). You can use it at [poll.nobloat.org](https://poll.nobloat.org). There is no \"pro version\" of it.\n\n## There was Doodle\n\nI usually used Doodle for that, but apparently a lot has happened there over the past year, and the path to [enshi*\\**ation has proceeded](https://www.youtube.com/watch?v=T4Upf_B9RLQ) a lot further:\n\n- You have to leave your e-mail address even as a participant.\n- You can no longer have more than one poll in total in the \"free account\", even if the old one is already closed.\n- You can't export your participant list.\n- Loading the poll page takes around 3 seconds, and downloads 4.7 MB JavaScript.\n- You are flashed by so many pop-ups.\n\nThere is of course [nuudel](https://nuudel.digitalcourage.de/) and a couple other [acceptable alternatives](https://www.heise.de/tipps-tricks/Doodle-Alternativen-Die-besten-Tools-zur-Terminfindung-4964023.html), but I wanted something I can easily self-host on my quite dated own cabinet \"server\" (an old ThinkPad X260 of course).\n\nMy cabinet \"server\", a ThinkPad X260\n\nIt's been over a year since I last built something *nobloat-style*, so I thought this is the perfect opportunity to spend some hours and build something I personally **enjoy building and using**.\n\n## My requirements checklist\n\nThe goal was to have a tool ready by the end of the weekend that allows me to finally send out a poll for when to do the \"Punsch\" event.\n\n### Functional\n\n- Optionally send out e-mails to participants (if they choose to by offering an optional e-mail address)\n- Allow public poll creation without a register/login dance\n- Basic spam protection via a simple proof of work performed by the browser\n- [i18n](https://github.com/nobloat/poll/tree/main/i18n) support\n- [basic theming](https://github.com/nobloat/poll/tree/main/static/themes) to allow for branding and make it appropriate for different scenarios (birthday parties, dinner invite, business meetings, etc.)\n\n### Non-functional\n\n- Having fun in the process and create something I can enjoy looking at\n- Single statically linked cross-compilable binary I can basically deploy on anything that offers a filesystem.\n- As few external build-time and run-time dependencies as possible, so this stays low maintenance in the upcoming years.\n- Keep the state in plain text files so it's easy to reason about, backup, migrate, modify, bulk edit/create/delete.\n- Absolutely no **node, npm, yarn** madness. I usually build most of my web frontends with [Svelte](https://svelte.dev/) or [SvelteKit](https://svelte.dev/docs/kit/introduction), but this requires npm and a ton of other node modules. Again, keeping this low-maintenance without [renovate-bot](https://docs.renovatebot.com/) yelling at me every other day.\n- Simple configuration that works inside and outside of containers\n\n[As it turns out](https://github.com/nobloat/poll/blob/main/go.mod), **plain go std-lib** is completely sufficient to build a simple replacement for Doodle.\n\n```\ncat go.mod package.json\nmodule github.com/nobloat/poll\n\ngo 1.27.0\ncat: package.json: No such file or directory\n```\n\n## Bypassing signup and login\n\nTo avoid making users register and log in, every poll gets a secret owner token when it is created. The link containing it is all you need to manage the poll.\n\nIf the owner chooses to provide a mail address, this unique link is sent to them. If not, they can copy/paste or bookmark it.\n\n```\nHi Peter,\n\nyour poll \"Punsch Event\" is live.\n\nShare this link with participants:\nhttp://localhost:8737/p/d7fminnmxnvygaxg\n\nManage it (see answers, edit, close, delete):\nhttp://localhost:8737/p/d7fminnmxnvygaxg/m/TMP6BZXB2SE4TOP3PVZODGRS6U\n\nThe manage link is personal. Anyone with it can edit, close or delete the poll, so don't share it.\n```\n\nThe same happens when answering a poll. Every answer gets its own token, and the link allows you to view, edit or delete your answer.\n\n```\nHi Peter,\n\nthanks for answering \"Punsch Event\". Here is your personal link to change or remove it:\n\nhttp://localhost:8737/p/d7fminnmxnvygaxg/a/CGYZDMGL7UJ5WA4RR4ON66HBEN\n\nYou'll be notified by email when the final decision is made.\n\nThis link is personal, don't share it. If you didn't answer this poll, ignore this email or use the link to remove the answer.\n```\n\nAll mails are localized, just like the web interface. The language is remembered per participant, so everyone gets their mails in the language they used when answering, even if the poll owner speaks a different one.\n\n## Creating a poll or event\n\nYou can create a new poll [here](https://poll.nobloat.org/new?lang=en)\n\nThe poll creation form\n\nIt asks you to enter a title and a description.\n\nOptions are simply a `<textarea>` where each line represents an option.\n\nYou can also switch between *single choice* and *multiple choice*.\n\nFor better privacy among voters, you can also choose whether participants can see each other's answers.\n\nThere is no separate \"event\" poll type. If an option looks like a date, the poll becomes an event poll by itself (more on that [below](#calendar-invites)). Since options are just lines of text, you can also copy and paste a whole list of them in one go.\n\n## Answering a poll\n\nEditing an answer via the personal link\n\nIf the poll creator supplied an e-mail address, they receive an update mail when answers are coming in.\n\n```\nNew answer from Grace: Pizza Friday\n\n# Answers\n\n01 Margherita  [2] ■■\n   Ada Lovelace, Grace Hopper\n02 Diavola  [2] ■■\n   Linus, Grace Hopper\n03 Quattro Formaggi  [2] ■■\n   Ada Lovelace, Grace Hopper\n04 Hawaii  [1] ■\n   Rubber Duck\n\nhttp://localhost:8737/p/pizza-friday/m/ASEIRUQD7POJBVG6VITTHGCIIK\n```\n\n## Calendar invites\n\nMost of my polls are really about finding a date. So when an option looks like a date, the poll treats it as one. All of these work:\n\n```\n2026-12-12\n12.12.2026\n12.12.\nSat, 12.12.2026, 18:00–22:00\n12.12. um 18:00 Uhr\n```\n\nA missing year means the next time that date comes up. A date without a time becomes an all-day event, and a start time without an end time gets one hour. If the end time is earlier than the start, the event runs past midnight. Anything that isn't a valid date, like `31.02.`, simply stays a normal text option.\n\nWhen the poll is closed and the winning option is a date, everybody who left an e-mail address gets the result with an `invite.ics` attached:\n\n```\nBEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//nobloat//poll//EN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nUID:zeitkapsl-punsch@poll.nobloat.org\nDTSTAMP:20261205T091500Z\nDTSTART:20261212T170000Z\nDTEND:20261212T210000Z\nSUMMARY:zeitkapsl Punsch\nDESCRIPTION:Punsch\\, Kekse and old stories.\nEND:VEVENT\nEND:VCALENDAR\n```\n\nOne click and it's in their calendar. The whole format is about 40 lines of Go: escaping, folding lines at 75 bytes, and joining them with `\\r\\n`. There's no need for a library.\n\n## Keyboard shortcuts\n\nAll actions can be reached via keyboard shortcuts so you don't have to use the mouse at all. Each shortcut is shown as a small `<kbd>` hint next to where it applies: `1`–`9` toggle the options, `n` jumps to the name field, `e` to the e-mail field, `s` submits, and `Ctrl+Enter` submits from inside any field. On the manage page, `c` copies the share link and `1`–`9` pick the final option.\n\nUnder the hood, it's just a `data-key` attribute in the HTML templates and a single `keydown` listener in `app.js`.\n\n## Spam protection without a captcha\n\nSince anybody can create polls on [poll.nobloat.org](https://poll.nobloat.org), I needed some basic protection against bots, but without captchas or third-party services.\n\nBefore the server accepts a form, the browser has to burn a bit of CPU. For one human submitting one poll, that's unnoticeable. For a bot submitting thousands, it adds up.\n\n**1. Get a challenge.** On submit, the browser fetches `GET /pow`. The server answers with the difficulty and a challenge:\n\n```\n16 1760180000.K3J5ZQ7XW2M4N6P8R2T4V6X8YA.9f2c…e41a\n```\n\nThe challenge is a timestamp, a random value and an HMAC over both, signed with a key that only lives in memory. Because of the signature, the server doesn't have to store issued challenges. It can recognize its own later.\n\n**2. Solve it.** The browser counts up a number (the nonce) until `SHA-256(challenge + nonce)` starts with 16 zero bits:\n\n```\nconst zeroBits = (buf) => {\n  const b = new Uint8Array(buf);\n  let n = 0;\n  for (const x of b) {\n    if (x === 0) { n += 8; continue; }\n    return n + Math.clz32(x) - 24;\n  }\n  return n;\n};\n\nasync function solve(challenge, bits) {\n  const enc = new TextEncoder();\n  const batch = 64;\n  for (let n = 0; ; n += batch) {\n    const hashes = await Promise.all(Array.from({ length: batch },\n      (_, i) => crypto.subtle.digest('SHA-256', enc.encode(challenge + (n + i)))));\n    const i = hashes.findIndex((h) => zeroBits(h) >= bits);\n    if (i >= 0) return String(n + i);\n  }\n}\n```\n\nEach extra zero bit halves the chance that a random hash qualifies, so 16 bits means about 2¹⁶ ≈ 65,000 tries on average. `crypto.subtle.digest` is built into every browser, so no library is needed. It's async, which is why the hashes are computed in batches of 64 with `Promise.all` rather than awaited one by one.\n\n**3. Submit with proof.** A global `submit` listener holds the form back, solves the challenge and adds both values as hidden fields before sending it off:\n\n```\nasync function proof() {\n  const [bits, pow] = (await (await fetch('/pow')).text()).split(' ');\n  return { pow, nonce: await solve(pow, +bits) };\n}\n\ndocument.addEventListener('submit', async (e) => {\n  const form = e.target;\n  if (form.method !== 'post') return;\n  if (form.dataset.proved) { delete form.dataset.proved; return; }\n  e.preventDefault();\n  // …\n  const p = await proof();\n  for (const k in p) form.insertAdjacentHTML('beforeend', `<input type=\"hidden\" name=\"${k}\" value=\"${p[k]}\" data-pow>`);\n  form.dataset.proved = 1;\n  // …\n  form.requestSubmit(e.submitter);\n});\n```\n\nThe forms themselves are plain HTML forms. They don't know anything about this.\n\n**4. Verify.** Every `POST` on the server goes through a middleware that checks the proof. Verifying costs a single hash, compared to the client's ~65,000:\n\n- Does the HMAC match? Otherwise the challenge is forged.\n- Is it younger than 2 hours?\n- Does `SHA-256(challenge + nonce)` really start with 16 zero bits?\n- Has this challenge been used before? Used challenges are kept in memory until they expire, so a solved one can't be replayed.\n\nThis won't stop a determined attacker, but it makes mass submissions expensive enough that dumb bots move on.\n\n## Skipping the database\n\nPostgres was out of the question as a runtime dependency for this project for obvious reasons.\n\nSQLite would have been fine for me, but the common Go driver requires `CGO`, which introduces a whole other set of problems when cross compiling. Pure-Go ports like `modernc.org/sqlite` exist, but they are yet another external dependency. So I decided to keep it even more minimal and just store the polls in simple `.json` files.\n\nAll modifications go through a single global lock. That's the bottleneck if thousands of people vote at the same time, which is not a problem I have. The poll definition as well as the answers from voters are stored in the same file.\n\nThe file is named after the generated poll ID: `data/polls/<id>.json`\n\n```\ncat data/polls/d7fminnmxnvygaxg.json\n{\n  \"title\": \"Punsch Event\",\n  \"description\": \"\",\n  \"multiple\": true,\n  \"resultsVisible\": false,\n  \"options\": [\n    { \"text\": \"12.10.2026\", \"start\": \"2026-10-12T00:00:00+02:00\", \"end\": \"2026-10-13T00:00:00+02:00\", \"allDay\": true },\n    { \"text\": \"13.10.2026\", \"start\": \"2026-10-13T00:00:00+02:00\", \"end\": \"2026-10-14T00:00:00+02:00\", \"allDay\": true },\n    { \"text\": \"15.10.2026\", \"start\": \"2026-10-15T00:00:00+02:00\", \"end\": \"2026-10-16T00:00:00+02:00\", \"allDay\": true }\n  ],\n  \"answers\": [\n    { \"token\": \"CGYZDMGL7UJ5WA4RR4ON66HBEN\", \"name\": \"Peter\", \"email\": \"\", \"lang\": \"en\", \"choices\": [1], \"created\": \"2026-10-11T13:00:27.373786142+02:00\" }\n  ],\n  \"closed\": false,\n  \"outcome\": 0,\n  \"created\": \"2026-10-11T12:59:34.699813479+02:00\",\n  \"owner_name\": \"Peter\",\n  \"owner_email\": \"\",\n  \"owner_lang\": \"en\",\n  \"owner_token\": \"TMP6BZXB2SE4TOP3PVZODGRS6U\",\n  \"theme\": \"minimal\"\n}\n```\n\nBut what happens if the process crashes or the power goes out in the middle of writing a poll? With a plain `os.WriteFile` you could end up with a truncated, half-written `.json` file, and the poll would be gone.\n\nTo avoid that, [every write is atomic](https://github.com/nobloat/poll/blob/main/store.go): the new content goes into a temporary file in the same directory, which is flushed to disk and then renamed over the old file. A `rename` within the same filesystem is atomic, so the poll file is always either the old or the new version.\n\n```\nf, _ := os.CreateTemp(s.dir, \".tmp-*\")\nf.Write(b)\nf.Sync()                            // content is on disk\nf.Close()\nos.Rename(f.Name(), s.path(p.ID))   // atomic swap\ns.syncDir()                         // the rename itself is on disk\n```\n\nThe last `fsync` on the directory makes sure the rename survives a power loss too. If we crash before the rename, only a stale `.tmp-*` file is left behind, and those get cleaned up on the next startup.\n\n## Reliably sending mails\n\nWhen a poll is closed, every participant who left an e-mail address gets the outcome, so mails must not get lost. Therefore, we need some kind of reliable e-mail sending mechanism.\n\nGo ships `SMTP` support out of the box with `net/smtp`. The easiest way is to just start one `goroutine` per recipient and call it a day. But SMTP can fail or we can decide to restart the application while mails are sent out.\n\nTo avoid such issues, one usually uses an asynchronous job queue like `redis` or [river queue](https://riverqueue.com/) for postgres. Of course neither is an option for this project.\n\nSo we are keeping a [persisted outbox](https://github.com/nobloat/poll/blob/main/outbox.go) under `./data/mail` that stores each mail that needs to be sent out on disk.\n\nOn application startup we also check for not yet sent messages.\n\nThis gives us an [at-least-once](https://en.wikipedia.org/wiki/Message_queue#Delivery_guarantees) delivery guarantee, with duplicates only if we crash mid-send.\n\nShutdown of the application [happens gracefully](https://github.com/nobloat/poll/blob/820d66036f974ffbc0d8e270ce57fe09ad9985a2/main.go#L94-L104) and waits for the mail currently being sent.\n\n## Deployment\n\nSince there is no `CGO`, the binary is built fully static:\n\n```\nCGO_ENABLED=0 go build -tags timetzdata,nethttpomithttp2 -trimpath -ldflags=\"-s -w\" -o poll .\nupx -q --best poll\n```\n\n`timetzdata` embeds the time zone database, so the binary doesn't even need `/usr/share/zoneinfo` on the target system. `nethttpomithttp2` drops the HTTP/2 implementation, which isn't needed behind a reverse proxy anyway.\n\nThis allows the [Docker image](https://github.com/nobloat/poll/blob/main/Dockerfile) to be built `FROM scratch`: it contains nothing but the CA certificates (for SMTP via TLS) and the binary itself. It runs as an unprivileged user, and the binary refuses to start as `root` altogether.\n\nConfiguration is done via environment variables only, which works the same way inside a container, in a systemd unit or in a shell on my ThinkPad. On startup all settings are validated and every error is reported at once.\n\n## 9.1 MB → 3.5 MB binary size\n\n9.1 MB is not as tiny for a poll tool as I hoped for, so I looked at which packages take up the space.\n\n```\nCGO_ENABLED=0 go build -tags timetzdata,nethttpomithttp2 -trimpath -o poll-sym .\ngo tool nm -size poll-sym \\\n  | awk '$3 !~ /[Bb]/ { match($4, /^[^.(]*(\\/[^.(]*)*/); s[substr($4, 1, RLENGTH)] += $2 }\n         END { for (p in s) printf \"%8.1f KB  %s\\n\", s[p]/1024, p }' \\\n  | sort -rn\n```\n\nGrouped a bit further:\n\n```\ncrypto/*                         1.2 MB\nruntime, reflect, internal/*     0.9 MB\ntype information                 0.6 MB\nnet, net/http, net/smtp          0.5 MB\nencoding/json                    0.3 MB\nvendor/golang.org/x/*            0.2 MB\nhtml/template, text/template     0.2 MB\nnobloat/poll                     0.1 MB\n```\n\nMost of this is expected, we need the whole `crypto` package for the TLS stack for SMTP via `STARTTLS`.\n\nThere is an option to omit the HTTP/2 support, which we don't need because we run behind a reverse proxy anway.\n\n```\nfor tags in timetzdata,nethttpomithttp2 nethttpomithttp2 timetzdata; do\n  CGO_ENABLED=0 go build -tags \"$tags\" -trimpath -ldflags=\"-s -w\" -o poll-test .\n  echo \"$(stat -c %s poll-test) $tags\"\ndone\ndu -cb static templates i18n mail | tail -1\n```\n\n- `timetzdata` adds **~400 KB**: the zipped time zone database\n- `nethttpomithttp2` saves **~410 KB**: the HTTP/2 implementation\n- All embedded templates, CSS, JS and translations together are only **~78 KB**\n\n### Squeezing it with UPX\n\nSince most of the binary can't be removed, the last step is to compress it with [UPX](https://upx.github.io/). UPX packs the executable and prepends a small stub that decompresses it into memory on startup:\n\n```\nupx -q --best poll\n```\n\n```\n9,584,800 bytes  poll (stripped)\n3,624,388 bytes  poll (upx --best)\n```\n\nThat's **3.5 MB**, a reduction of about 62%. It doesn't come for free though. Startup time increases quite a lot, but this doesn't really bother me.\n\n```\n                 startup   peak memory\npoll             2 ms      12.4 MB\npoll (upx)       32 ms     14.7 MB\n```\n\n## Summary\n\nIt is now Sunday afternoon, 14:00 and this was fun to build, even though it turned out to be not as minimal as I hoped for. But I do like that it doesn't use any external dependencies except for the SMTP gateway to send out emails, and even these are optional.\n\n**I have now a poll tool I enjoy using.** - mission accomplished.\n\nI could still leave out the JavaScript stuff itself, but I like it for the shortcuts, proof-of-work and interactivity improvements it delivers.\n\nEverybody else [is welcome to use](https://poll.nobloat.org) it as well. No service guarantees though, I tend to reboot my X260 from time to time.\n\nIt's licensed under [AGPL-3.0](https://github.com/nobloat/poll/blob/main/LICENSE).\n","body_html":"<p>So, I wanted to send out a poll for our yearly &quot;zeitkapsl Punsch&quot; event, where we invite people who helped <a href=\"https://zeitkapsl.eu\" rel=\"nofollow ugc noopener\">zeitkapsl</a> become what it is today. Early customers, former development team members, etc. Well...</p>\n<blockquote><p>&quot;... I opened up Doodle, trying to create a new poll for an event. I was immediately greeted with flashing popups stating I have to upgrade to pro now. I kept searching for where to delete my existing polls. It was not easy. I finally figured it out and was able to create the poll for my punsch event. But it left me wondering: how is everybody else tolerating this?&quot;</p></blockquote>\n<p>A hand-drawn poll: &quot;How to name the office plant?&quot; Options: go fern(), /dev/leaf, Monstera Monolith and Photosynthesis as a Service</p>\n<h2 id=\"tl-dr\">TL;DR</h2>\n<p><a href=\"https://github.com/nobloat/poll\" rel=\"nofollow ugc noopener\">nobloat/poll</a> is a self-hostable single-binary Doodle alternative written in plain <code>go</code> with <a href=\"https://github.com/nobloat/poll/blob/main/go.mod\" rel=\"nofollow ugc noopener\">no further dependencies</a>. It doesn&#39;t require a database, job queue, npm or node. It works without signups. Every poll is a single JSON file, the whole thing ships as one 3.5 MB binary, and a page loads ~27 KB (6.9 kB + 20 kB uncompressed JavaScript and CSS). You can use it at <a href=\"https://poll.nobloat.org\" rel=\"nofollow ugc noopener\">poll.nobloat.org</a>. There is no &quot;pro version&quot; of it.</p>\n<h2 id=\"there-was-doodle\">There was Doodle</h2>\n<p>I usually used Doodle for that, but apparently a lot has happened there over the past year, and the path to <a href=\"https://www.youtube.com/watch?v=T4Upf_B9RLQ\" rel=\"nofollow ugc noopener\">enshi***ation has proceeded</a> a lot further:</p>\n<ul><li>You have to leave your e-mail address even as a participant.</li><li>You can no longer have more than one poll in total in the &quot;free account&quot;, even if the old one is already closed.</li><li>You can&#39;t export your participant list.</li><li>Loading the poll page takes around 3 seconds, and downloads 4.7 MB JavaScript.</li><li>You are flashed by so many pop-ups.</li></ul>\n<p>There is of course <a href=\"https://nuudel.digitalcourage.de/\" rel=\"nofollow ugc noopener\">nuudel</a> and a couple other <a href=\"https://www.heise.de/tipps-tricks/Doodle-Alternativen-Die-besten-Tools-zur-Terminfindung-4964023.html\" rel=\"nofollow ugc noopener\">acceptable alternatives</a>, but I wanted something I can easily self-host on my quite dated own cabinet &quot;server&quot; (an old ThinkPad X260 of course).</p>\n<p>My cabinet &quot;server&quot;, a ThinkPad X260</p>\n<p>It&#39;s been over a year since I last built something <em>nobloat-style</em>, so I thought this is the perfect opportunity to spend some hours and build something I personally <strong>enjoy building and using</strong>.</p>\n<h2 id=\"my-requirements-checklist\">My requirements checklist</h2>\n<p>The goal was to have a tool ready by the end of the weekend that allows me to finally send out a poll for when to do the &quot;Punsch&quot; event.</p>\n<h3 id=\"functional\">Functional</h3>\n<ul><li>Optionally send out e-mails to participants (if they choose to by offering an optional e-mail address)</li><li>Allow public poll creation without a register/login dance</li><li>Basic spam protection via a simple proof of work performed by the browser</li><li><a href=\"https://github.com/nobloat/poll/tree/main/i18n\" rel=\"nofollow ugc noopener\">i18n</a> support</li><li><a href=\"https://github.com/nobloat/poll/tree/main/static/themes\" rel=\"nofollow ugc noopener\">basic theming</a> to allow for branding and make it appropriate for different scenarios (birthday parties, dinner invite, business meetings, etc.)</li></ul>\n<h3 id=\"non-functional\">Non-functional</h3>\n<ul><li>Having fun in the process and create something I can enjoy looking at</li><li>Single statically linked cross-compilable binary I can basically deploy on anything that offers a filesystem.</li><li>As few external build-time and run-time dependencies as possible, so this stays low maintenance in the upcoming years.</li><li>Keep the state in plain text files so it&#39;s easy to reason about, backup, migrate, modify, bulk edit/create/delete.</li><li>Absolutely no <strong>node, npm, yarn</strong> madness. I usually build most of my web frontends with <a href=\"https://svelte.dev/\" rel=\"nofollow ugc noopener\">Svelte</a> or <a href=\"https://svelte.dev/docs/kit/introduction\" rel=\"nofollow ugc noopener\">SvelteKit</a>, but this requires npm and a ton of other node modules. Again, keeping this low-maintenance without <a href=\"https://docs.renovatebot.com/\" rel=\"nofollow ugc noopener\">renovate-bot</a> yelling at me every other day.</li><li>Simple configuration that works inside and outside of containers</li></ul>\n<p><a href=\"https://github.com/nobloat/poll/blob/main/go.mod\" rel=\"nofollow ugc noopener\">As it turns out</a>, <strong>plain go std-lib</strong> is completely sufficient to build a simple replacement for Doodle.</p>\n<pre><code>cat go.mod package.json\nmodule github.com/nobloat/poll\n\ngo 1.27.0\ncat: package.json: No such file or directory</code></pre>\n<h2 id=\"bypassing-signup-and-login\">Bypassing signup and login</h2>\n<p>To avoid making users register and log in, every poll gets a secret owner token when it is created. The link containing it is all you need to manage the poll.</p>\n<p>If the owner chooses to provide a mail address, this unique link is sent to them. If not, they can copy/paste or bookmark it.</p>\n<pre><code>Hi Peter,\n\nyour poll &quot;Punsch Event&quot; is live.\n\nShare this link with participants:\nhttp://localhost:8737/p/d7fminnmxnvygaxg\n\nManage it (see answers, edit, close, delete):\nhttp://localhost:8737/p/d7fminnmxnvygaxg/m/TMP6BZXB2SE4TOP3PVZODGRS6U\n\nThe manage link is personal. Anyone with it can edit, close or delete the poll, so don&#39;t share it.</code></pre>\n<p>The same happens when answering a poll. Every answer gets its own token, and the link allows you to view, edit or delete your answer.</p>\n<pre><code>Hi Peter,\n\nthanks for answering &quot;Punsch Event&quot;. Here is your personal link to change or remove it:\n\nhttp://localhost:8737/p/d7fminnmxnvygaxg/a/CGYZDMGL7UJ5WA4RR4ON66HBEN\n\nYou&#39;ll be notified by email when the final decision is made.\n\nThis link is personal, don&#39;t share it. If you didn&#39;t answer this poll, ignore this email or use the link to remove the answer.</code></pre>\n<p>All mails are localized, just like the web interface. The language is remembered per participant, so everyone gets their mails in the language they used when answering, even if the poll owner speaks a different one.</p>\n<h2 id=\"creating-a-poll-or-event\">Creating a poll or event</h2>\n<p>You can create a new poll <a href=\"https://poll.nobloat.org/new?lang=en\" rel=\"nofollow ugc noopener\">here</a></p>\n<p>The poll creation form</p>\n<p>It asks you to enter a title and a description.</p>\n<p>Options are simply a <code>&lt;textarea&gt;</code> where each line represents an option.</p>\n<p>You can also switch between <em>single choice</em> and <em>multiple choice</em>.</p>\n<p>For better privacy among voters, you can also choose whether participants can see each other&#39;s answers.</p>\n<p>There is no separate &quot;event&quot; poll type. If an option looks like a date, the poll becomes an event poll by itself (more on that <a href=\"#calendar-invites\">below</a>). Since options are just lines of text, you can also copy and paste a whole list of them in one go.</p>\n<h2 id=\"answering-a-poll\">Answering a poll</h2>\n<p>Editing an answer via the personal link</p>\n<p>If the poll creator supplied an e-mail address, they receive an update mail when answers are coming in.</p>\n<pre><code>New answer from Grace: Pizza Friday\n\n# Answers\n\n01 Margherita  [2] ■■\n   Ada Lovelace, Grace Hopper\n02 Diavola  [2] ■■\n   Linus, Grace Hopper\n03 Quattro Formaggi  [2] ■■\n   Ada Lovelace, Grace Hopper\n04 Hawaii  [1] ■\n   Rubber Duck\n\nhttp://localhost:8737/p/pizza-friday/m/ASEIRUQD7POJBVG6VITTHGCIIK</code></pre>\n<h2 id=\"calendar-invites\">Calendar invites</h2>\n<p>Most of my polls are really about finding a date. So when an option looks like a date, the poll treats it as one. All of these work:</p>\n<pre><code>2026-12-12\n12.12.2026\n12.12.\nSat, 12.12.2026, 18:00–22:00\n12.12. um 18:00 Uhr</code></pre>\n<p>A missing year means the next time that date comes up. A date without a time becomes an all-day event, and a start time without an end time gets one hour. If the end time is earlier than the start, the event runs past midnight. Anything that isn&#39;t a valid date, like <code>31.02.</code>, simply stays a normal text option.</p>\n<p>When the poll is closed and the winning option is a date, everybody who left an e-mail address gets the result with an <code>invite.ics</code> attached:</p>\n<pre><code>BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//nobloat//poll//EN\nMETHOD:PUBLISH\nBEGIN:VEVENT\nUID:zeitkapsl-punsch@poll.nobloat.org\nDTSTAMP:20261205T091500Z\nDTSTART:20261212T170000Z\nDTEND:20261212T210000Z\nSUMMARY:zeitkapsl Punsch\nDESCRIPTION:Punsch\\, Kekse and old stories.\nEND:VEVENT\nEND:VCALENDAR</code></pre>\n<p>One click and it&#39;s in their calendar. The whole format is about 40 lines of Go: escaping, folding lines at 75 bytes, and joining them with <code>\\r\\n</code>. There&#39;s no need for a library.</p>\n<h2 id=\"keyboard-shortcuts\">Keyboard shortcuts</h2>\n<p>All actions can be reached via keyboard shortcuts so you don&#39;t have to use the mouse at all. Each shortcut is shown as a small <code>&lt;kbd&gt;</code> hint next to where it applies: <code>1</code>–<code>9</code> toggle the options, <code>n</code> jumps to the name field, <code>e</code> to the e-mail field, <code>s</code> submits, and <code>Ctrl+Enter</code> submits from inside any field. On the manage page, <code>c</code> copies the share link and <code>1</code>–<code>9</code> pick the final option.</p>\n<p>Under the hood, it&#39;s just a <code>data-key</code> attribute in the HTML templates and a single <code>keydown</code> listener in <code>app.js</code>.</p>\n<h2 id=\"spam-protection-without-a-captcha\">Spam protection without a captcha</h2>\n<p>Since anybody can create polls on <a href=\"https://poll.nobloat.org\" rel=\"nofollow ugc noopener\">poll.nobloat.org</a>, I needed some basic protection against bots, but without captchas or third-party services.</p>\n<p>Before the server accepts a form, the browser has to burn a bit of CPU. For one human submitting one poll, that&#39;s unnoticeable. For a bot submitting thousands, it adds up.</p>\n<p><strong>1. Get a challenge.</strong> On submit, the browser fetches <code>GET /pow</code>. The server answers with the difficulty and a challenge:</p>\n<pre><code>16 1760180000.K3J5ZQ7XW2M4N6P8R2T4V6X8YA.9f2c…e41a</code></pre>\n<p>The challenge is a timestamp, a random value and an HMAC over both, signed with a key that only lives in memory. Because of the signature, the server doesn&#39;t have to store issued challenges. It can recognize its own later.</p>\n<p><strong>2. Solve it.</strong> The browser counts up a number (the nonce) until <code>SHA-256(challenge + nonce)</code> starts with 16 zero bits:</p>\n<pre><code>const zeroBits = (buf) =&gt; {\n  const b = new Uint8Array(buf);\n  let n = 0;\n  for (const x of b) {\n    if (x === 0) { n += 8; continue; }\n    return n + Math.clz32(x) - 24;\n  }\n  return n;\n};\n\nasync function solve(challenge, bits) {\n  const enc = new TextEncoder();\n  const batch = 64;\n  for (let n = 0; ; n += batch) {\n    const hashes = await Promise.all(Array.from({ length: batch },\n      (_, i) =&gt; crypto.subtle.digest(&#39;SHA-256&#39;, enc.encode(challenge + (n + i)))));\n    const i = hashes.findIndex((h) =&gt; zeroBits(h) &gt;= bits);\n    if (i &gt;= 0) return String(n + i);\n  }\n}</code></pre>\n<p>Each extra zero bit halves the chance that a random hash qualifies, so 16 bits means about 2¹⁶ ≈ 65,000 tries on average. <code>crypto.subtle.digest</code> is built into every browser, so no library is needed. It&#39;s async, which is why the hashes are computed in batches of 64 with <code>Promise.all</code> rather than awaited one by one.</p>\n<p><strong>3. Submit with proof.</strong> A global <code>submit</code> listener holds the form back, solves the challenge and adds both values as hidden fields before sending it off:</p>\n<pre><code>async function proof() {\n  const [bits, pow] = (await (await fetch(&#39;/pow&#39;)).text()).split(&#39; &#39;);\n  return { pow, nonce: await solve(pow, +bits) };\n}\n\ndocument.addEventListener(&#39;submit&#39;, async (e) =&gt; {\n  const form = e.target;\n  if (form.method !== &#39;post&#39;) return;\n  if (form.dataset.proved) { delete form.dataset.proved; return; }\n  e.preventDefault();\n  // …\n  const p = await proof();\n  for (const k in p) form.insertAdjacentHTML(&#39;beforeend&#39;, `&lt;input type=&quot;hidden&quot; name=&quot;${k}&quot; value=&quot;${p[k]}&quot; data-pow&gt;`);\n  form.dataset.proved = 1;\n  // …\n  form.requestSubmit(e.submitter);\n});</code></pre>\n<p>The forms themselves are plain HTML forms. They don&#39;t know anything about this.</p>\n<p><strong>4. Verify.</strong> Every <code>POST</code> on the server goes through a middleware that checks the proof. Verifying costs a single hash, compared to the client&#39;s ~65,000:</p>\n<ul><li>Does the HMAC match? Otherwise the challenge is forged.</li><li>Is it younger than 2 hours?</li><li>Does <code>SHA-256(challenge + nonce)</code> really start with 16 zero bits?</li><li>Has this challenge been used before? Used challenges are kept in memory until they expire, so a solved one can&#39;t be replayed.</li></ul>\n<p>This won&#39;t stop a determined attacker, but it makes mass submissions expensive enough that dumb bots move on.</p>\n<h2 id=\"skipping-the-database\">Skipping the database</h2>\n<p>Postgres was out of the question as a runtime dependency for this project for obvious reasons.</p>\n<p>SQLite would have been fine for me, but the common Go driver requires <code>CGO</code>, which introduces a whole other set of problems when cross compiling. Pure-Go ports like <code>modernc.org/sqlite</code> exist, but they are yet another external dependency. So I decided to keep it even more minimal and just store the polls in simple <code>.json</code> files.</p>\n<p>All modifications go through a single global lock. That&#39;s the bottleneck if thousands of people vote at the same time, which is not a problem I have. The poll definition as well as the answers from voters are stored in the same file.</p>\n<p>The file is named after the generated poll ID: <code>data/polls/&lt;id&gt;.json</code></p>\n<pre><code>cat data/polls/d7fminnmxnvygaxg.json\n{\n  &quot;title&quot;: &quot;Punsch Event&quot;,\n  &quot;description&quot;: &quot;&quot;,\n  &quot;multiple&quot;: true,\n  &quot;resultsVisible&quot;: false,\n  &quot;options&quot;: [\n    { &quot;text&quot;: &quot;12.10.2026&quot;, &quot;start&quot;: &quot;2026-10-12T00:00:00+02:00&quot;, &quot;end&quot;: &quot;2026-10-13T00:00:00+02:00&quot;, &quot;allDay&quot;: true },\n    { &quot;text&quot;: &quot;13.10.2026&quot;, &quot;start&quot;: &quot;2026-10-13T00:00:00+02:00&quot;, &quot;end&quot;: &quot;2026-10-14T00:00:00+02:00&quot;, &quot;allDay&quot;: true },\n    { &quot;text&quot;: &quot;15.10.2026&quot;, &quot;start&quot;: &quot;2026-10-15T00:00:00+02:00&quot;, &quot;end&quot;: &quot;2026-10-16T00:00:00+02:00&quot;, &quot;allDay&quot;: true }\n  ],\n  &quot;answers&quot;: [\n    { &quot;token&quot;: &quot;CGYZDMGL7UJ5WA4RR4ON66HBEN&quot;, &quot;name&quot;: &quot;Peter&quot;, &quot;email&quot;: &quot;&quot;, &quot;lang&quot;: &quot;en&quot;, &quot;choices&quot;: [1], &quot;created&quot;: &quot;2026-10-11T13:00:27.373786142+02:00&quot; }\n  ],\n  &quot;closed&quot;: false,\n  &quot;outcome&quot;: 0,\n  &quot;created&quot;: &quot;2026-10-11T12:59:34.699813479+02:00&quot;,\n  &quot;owner_name&quot;: &quot;Peter&quot;,\n  &quot;owner_email&quot;: &quot;&quot;,\n  &quot;owner_lang&quot;: &quot;en&quot;,\n  &quot;owner_token&quot;: &quot;TMP6BZXB2SE4TOP3PVZODGRS6U&quot;,\n  &quot;theme&quot;: &quot;minimal&quot;\n}</code></pre>\n<p>But what happens if the process crashes or the power goes out in the middle of writing a poll? With a plain <code>os.WriteFile</code> you could end up with a truncated, half-written <code>.json</code> file, and the poll would be gone.</p>\n<p>To avoid that, <a href=\"https://github.com/nobloat/poll/blob/main/store.go\" rel=\"nofollow ugc noopener\">every write is atomic</a>: the new content goes into a temporary file in the same directory, which is flushed to disk and then renamed over the old file. A <code>rename</code> within the same filesystem is atomic, so the poll file is always either the old or the new version.</p>\n<pre><code>f, _ := os.CreateTemp(s.dir, &quot;.tmp-*&quot;)\nf.Write(b)\nf.Sync()                            // content is on disk\nf.Close()\nos.Rename(f.Name(), s.path(p.ID))   // atomic swap\ns.syncDir()                         // the rename itself is on disk</code></pre>\n<p>The last <code>fsync</code> on the directory makes sure the rename survives a power loss too. If we crash before the rename, only a stale <code>.tmp-*</code> file is left behind, and those get cleaned up on the next startup.</p>\n<h2 id=\"reliably-sending-mails\">Reliably sending mails</h2>\n<p>When a poll is closed, every participant who left an e-mail address gets the outcome, so mails must not get lost. Therefore, we need some kind of reliable e-mail sending mechanism.</p>\n<p>Go ships <code>SMTP</code> support out of the box with <code>net/smtp</code>. The easiest way is to just start one <code>goroutine</code> per recipient and call it a day. But SMTP can fail or we can decide to restart the application while mails are sent out.</p>\n<p>To avoid such issues, one usually uses an asynchronous job queue like <code>redis</code> or <a href=\"https://riverqueue.com/\" rel=\"nofollow ugc noopener\">river queue</a> for postgres. Of course neither is an option for this project.</p>\n<p>So we are keeping a <a href=\"https://github.com/nobloat/poll/blob/main/outbox.go\" rel=\"nofollow ugc noopener\">persisted outbox</a> under <code>./data/mail</code> that stores each mail that needs to be sent out on disk.</p>\n<p>On application startup we also check for not yet sent messages.</p>\n<p>This gives us an <a href=\"https://en.wikipedia.org/wiki/Message_queue#Delivery_guarantees\" rel=\"nofollow ugc noopener\">at-least-once</a> delivery guarantee, with duplicates only if we crash mid-send.</p>\n<p>Shutdown of the application <a href=\"https://github.com/nobloat/poll/blob/820d66036f974ffbc0d8e270ce57fe09ad9985a2/main.go#L94-L104\" rel=\"nofollow ugc noopener\">happens gracefully</a> and waits for the mail currently being sent.</p>\n<h2 id=\"deployment\">Deployment</h2>\n<p>Since there is no <code>CGO</code>, the binary is built fully static:</p>\n<pre><code>CGO_ENABLED=0 go build -tags timetzdata,nethttpomithttp2 -trimpath -ldflags=&quot;-s -w&quot; -o poll .\nupx -q --best poll</code></pre>\n<p><code>timetzdata</code> embeds the time zone database, so the binary doesn&#39;t even need <code>/usr/share/zoneinfo</code> on the target system. <code>nethttpomithttp2</code> drops the HTTP/2 implementation, which isn&#39;t needed behind a reverse proxy anyway.</p>\n<p>This allows the <a href=\"https://github.com/nobloat/poll/blob/main/Dockerfile\" rel=\"nofollow ugc noopener\">Docker image</a> to be built <code>FROM scratch</code>: it contains nothing but the CA certificates (for SMTP via TLS) and the binary itself. It runs as an unprivileged user, and the binary refuses to start as <code>root</code> altogether.</p>\n<p>Configuration is done via environment variables only, which works the same way inside a container, in a systemd unit or in a shell on my ThinkPad. On startup all settings are validated and every error is reported at once.</p>\n<h2 id=\"9-1-mb-3-5-mb-binary-size\">9.1 MB → 3.5 MB binary size</h2>\n<p>9.1 MB is not as tiny for a poll tool as I hoped for, so I looked at which packages take up the space.</p>\n<pre><code>CGO_ENABLED=0 go build -tags timetzdata,nethttpomithttp2 -trimpath -o poll-sym .\ngo tool nm -size poll-sym \\\n  | awk &#39;$3 !~ /[Bb]/ { match($4, /^[^.(]*(\\/[^.(]*)*/); s[substr($4, 1, RLENGTH)] += $2 }\n         END { for (p in s) printf &quot;%8.1f KB  %s\\n&quot;, s[p]/1024, p }&#39; \\\n  | sort -rn</code></pre>\n<p>Grouped a bit further:</p>\n<pre><code>crypto/*                         1.2 MB\nruntime, reflect, internal/*     0.9 MB\ntype information                 0.6 MB\nnet, net/http, net/smtp          0.5 MB\nencoding/json                    0.3 MB\nvendor/golang.org/x/*            0.2 MB\nhtml/template, text/template     0.2 MB\nnobloat/poll                     0.1 MB</code></pre>\n<p>Most of this is expected, we need the whole <code>crypto</code> package for the TLS stack for SMTP via <code>STARTTLS</code>.</p>\n<p>There is an option to omit the HTTP/2 support, which we don&#39;t need because we run behind a reverse proxy anway.</p>\n<pre><code>for tags in timetzdata,nethttpomithttp2 nethttpomithttp2 timetzdata; do\n  CGO_ENABLED=0 go build -tags &quot;$tags&quot; -trimpath -ldflags=&quot;-s -w&quot; -o poll-test .\n  echo &quot;$(stat -c %s poll-test) $tags&quot;\ndone\ndu -cb static templates i18n mail | tail -1</code></pre>\n<ul><li><code>timetzdata</code> adds <strong>~400 KB</strong>: the zipped time zone database</li><li><code>nethttpomithttp2</code> saves <strong>~410 KB</strong>: the HTTP/2 implementation</li><li>All embedded templates, CSS, JS and translations together are only <strong>~78 KB</strong></li></ul>\n<h3 id=\"squeezing-it-with-upx\">Squeezing it with UPX</h3>\n<p>Since most of the binary can&#39;t be removed, the last step is to compress it with <a href=\"https://upx.github.io/\" rel=\"nofollow ugc noopener\">UPX</a>. UPX packs the executable and prepends a small stub that decompresses it into memory on startup:</p>\n<pre><code>upx -q --best poll</code></pre>\n<pre><code>9,584,800 bytes  poll (stripped)\n3,624,388 bytes  poll (upx --best)</code></pre>\n<p>That&#39;s <strong>3.5 MB</strong>, a reduction of about 62%. It doesn&#39;t come for free though. Startup time increases quite a lot, but this doesn&#39;t really bother me.</p>\n<pre><code>                 startup   peak memory\npoll             2 ms      12.4 MB\npoll (upx)       32 ms     14.7 MB</code></pre>\n<h2 id=\"summary\">Summary</h2>\n<p>It is now Sunday afternoon, 14:00 and this was fun to build, even though it turned out to be not as minimal as I hoped for. But I do like that it doesn&#39;t use any external dependencies except for the SMTP gateway to send out emails, and even these are optional.</p>\n<p><strong>I have now a poll tool I enjoy using.</strong> - mission accomplished.</p>\n<p>I could still leave out the JavaScript stuff itself, but I like it for the shortcuts, proof-of-work and interactivity improvements it delivers.</p>\n<p>Everybody else <a href=\"https://poll.nobloat.org\" rel=\"nofollow ugc noopener\">is welcome to use</a> it as well. No service guarantees though, I tend to reboot my X260 from time to time.</p>\n<p>It&#39;s licensed under <a href=\"https://github.com/nobloat/poll/blob/main/LICENSE\" rel=\"nofollow ugc noopener\">AGPL-3.0</a>.</p>","headings":[{"level":2,"text":"TL;DR","id":"tl-dr"},{"level":2,"text":"There was Doodle","id":"there-was-doodle"},{"level":2,"text":"My requirements checklist","id":"my-requirements-checklist"},{"level":3,"text":"Functional","id":"functional"},{"level":3,"text":"Non-functional","id":"non-functional"},{"level":2,"text":"Bypassing signup and login","id":"bypassing-signup-and-login"},{"level":2,"text":"Creating a poll or event","id":"creating-a-poll-or-event"},{"level":2,"text":"Answering a poll","id":"answering-a-poll"},{"level":2,"text":"Calendar invites","id":"calendar-invites"},{"level":2,"text":"Keyboard shortcuts","id":"keyboard-shortcuts"},{"level":2,"text":"Spam protection without a captcha","id":"spam-protection-without-a-captcha"},{"level":2,"text":"Skipping the database","id":"skipping-the-database"},{"level":2,"text":"Reliably sending mails","id":"reliably-sending-mails"},{"level":2,"text":"Deployment","id":"deployment"},{"level":2,"text":"9.1 MB → 3.5 MB binary size","id":"9-1-mb-3-5-mb-binary-size"},{"level":3,"text":"Squeezing it with UPX","id":"squeezing-it-with-upx"},{"level":2,"text":"Summary","id":"summary"}]}}