Why We Chose Go for a Community Memorial Platform
A technical deep-dive into building Memorial: a privacy-first platform for families to create lasting tributes. Why we picked Go over Node.js and FastAPI, how we modeled granular privacy controls in PostgreSQL, and what we learned shipping a product built for emotional moments.
Memorial started with a harder brief than it sounds: build a platform where families can create pages for people they have lost, invite friends and relatives to contribute stories and photos, and control exactly who can see what.
Simple enough as a feature list. Much harder when you think through what it actually requires. The content is deeply personal. The access control model is more complex than a typical social app. The UX has to be gentle enough for people who are grieving. And the backend has to be fast, reliable, and small enough to run affordably as a product that is not yet monetized.
This post covers the technical decisions we made, particularly around the choice of Go for the backend, and what we would do the same or differently next time.
The Problem Space
Most content platforms share content by default and let users opt into privacy. That model is wrong for a memorial platform. A family creating a tribute for a loved one should not have to hunt through settings to prevent it from showing up in a Google search.
We designed Memorial around the opposite assumption: everything is private until the creator explicitly opens it up. Memorial pages can be public, private (invite-only), or anything in between. Contributors can be granted access to view, comment, or add their own content. The access model is the core product, not an afterthought.
That shaped every decision downstream: the data model, the auth layer, the API design, and the frontend.
Why Go
We build most of our backends in Python with FastAPI. It is fast to write, easy to reason about, and the ecosystem for data-heavy work is unmatched. For Mercurylist, FastAPI was the right call. For Memorial, we made a different choice.
Go was the right pick here for three reasons.
A single binary deployment. A Go backend compiles to a single static binary with no runtime dependencies. No Python environment to manage. No virtualenv, no pip, no version conflicts. You copy the binary to the server, write a systemd unit file, and it runs. For a product running on a small EC2 instance with no dedicated DevOps support, that matters. The operational surface area is as small as it can be.
Concurrency without complexity. Memorial handles concurrent reads and writes from multiple family members updating a page at the same time. Go's goroutine model makes that straightforward. The stdlib's net/http package handles concurrent requests natively without requiring an async framework layered on top. There is less to go wrong.
Predictable performance at low cost. Go's memory footprint is dramatically lower than a Python application under equivalent load. For a product in early access that may sit mostly idle, the difference between a t3.micro running comfortably and one swapping to disk is meaningful. Go gives you headroom.
The tradeoff is real: Go is more verbose than Python. Type definitions, explicit error handling, no list comprehensions. The development velocity is slower, especially for the data modeling and migration work early in the project. We accepted that tradeoff because the operational benefits over a multi-year product lifetime outweigh the extra lines of code during development.
Week 1 and 2: Discovery Before Code
Same approach as every build we do: two weeks of structured discovery before any production code.
For Memorial, the discovery sprint focused on two areas.
Access control modeling. Before writing schema, we mapped every permission combination the product needed to support. Who can view a page? Who can add a tribute? Who can edit the core memorial content? Who can invite new contributors? Who can delete the page? The answer is different for each role (creator, contributor, viewer) and each privacy setting (public, private, invite-only). We modeled all of this on paper before touching a database.
Emotional UX audit. This sounds soft but it has hard technical consequences. We spent time mapping the flows that users would encounter during difficult moments: creating a page for someone who just passed, receiving an invite to contribute to a memorial, seeing a tribute posted by someone they did not know. Those flows informed decisions about language, confirmation dialogs, notification design, and what actions should require explicit intent versus happen automatically. A "delete tribute" action, for example, requires a two-step confirmation. A "mark as private" action takes effect immediately with a visible confirmation banner, not a modal.
Those two weeks compressed the implementation timeline significantly. The remaining ten weeks were execution against a known plan.
The Data Model
The data model is straightforward to describe but took real care to get right.
Memorials are the core entity. Each memorial has an owner, a privacy setting, a title, a description, and a birth and death date for the person being remembered.
Contributors is a join table between memorials and users. Each row carries a role (viewer, contributor, admin) that determines what actions that user can take. When a memorial is set to public, any authenticated user implicitly has viewer access. When it is invite-only, only rows in this table grant access.
Tributes are the contributions from friends and family: a text tribute, a photo, or both. Each tribute is associated with a memorial and a user. Tributes are always private by default and visible only to people with access to the memorial.
Stories are longer-form content added by the memorial creator. They live on the memorial page and are subject to the same access rules as everything else.
We used UUID primary keys throughout. This was partly a habit from other projects and partly intentional: memorial page URLs do not expose incrementing IDs that would reveal how many memorials exist on the platform.
PostgreSQL 16 handles updated_at triggers automatically via a reusable function, and foreign key cascades clean up child records when a memorial is deleted. We do not soft-delete. When a family decides to remove a memorial, it is gone.
The Auth Layer
Auth is handled in the Go backend with JWT (HS256) tokens issued at login and validated on every request. Tokens are short-lived. Refresh is handled via a separate endpoint.
We used bcrypt for password hashing at cost factor 12. SendGrid handles transactional email: invite notifications, password resets, and tribute notifications.
One decision worth noting: we did not build social login (Google, Apple). It would have sped up onboarding, but it would also have introduced a dependency on third-party identity providers for a product that handles sensitive personal content. Families creating memorials should not have to worry about whether their Google account being compromised affects access to a tribute page. Plain email and password with good password requirements felt right for this product.
The Frontend
The Memorial frontend is vanilla HTML, CSS, and JavaScript with no framework. This was a deliberate choice.
The content on a memorial page needs to load fast and render correctly on any device, including older phones used by family members who are not particularly technical. A React or Next.js frontend would have added bundle weight without meaningful benefit for a product with limited interactivity.
We used Google Fonts: Playfair Display for headings (formal, warm, appropriate for the product) and Inter for body copy (legible, neutral). The visual language is intentionally quiet. No animations. No transitions. The product gets out of its own way.
The create and edit flows use progressive disclosure: you see only the fields relevant to the current step. Creating a memorial for someone should not feel like filling out a form.
Infrastructure
The stack runs on AWS EC2 with Docker and Nginx as the reverse proxy. Let's Encrypt handles TLS. Backups are pg_dump to S3 with 30-day retention.
The entire backend is a single Docker container with the compiled Go binary inside. Deployments are a docker pull and a container restart. Cold start time is under a second.
We run Nginx in front for SSL termination and to serve static assets. Go handles everything else.
What We Would Do Differently
Start with email deliverability earlier. We added SendGrid late in the build and had to retrofit email templates and notification logic into a backend that was not originally designed around it. The invite flow in particular required revisiting data models we thought were finished. Email is core to how collaborative platforms work. Design it in from the start.
Build the permissions middleware first. We wrote permission checks inline in individual route handlers early on, which meant refactoring when we realized we had duplicated the same logic across six different endpoints. We extracted it into a middleware layer eventually, but it would have been cleaner to build that abstraction before writing any route handlers.
More frontend componentization even without a framework. Vanilla JS is fine for a project this size, but we reinvented a few patterns (modal dialogs, form validation, toast notifications) that a small shared utility library would have solved once. You do not need React to benefit from reusable UI logic.
What Worked
Go for this use case was the right call. The binary is small, the server starts instantly, memory usage is low, and the concurrency model handles multi-user sessions without any additional infrastructure. Eleven months from now, when this product is running in the background and not actively being developed, it will still be running.
Discovery-first. The access control model we designed in week one survived the entire build without major revision. That is rare. It happened because we spent two full weeks on it before writing schema.
PostgreSQL for structured personal data. Some teams reach for a document database for content-heavy apps. We stayed with PostgreSQL and it was the right call. The access control model involves relational joins (user has access to memorial, memorial has tributes, tribute belongs to user). Those queries are simple in SQL and would have been awkward to replicate in a document store.
The Result
Memorial is live at memorial.signalshiftlabs.com. Families can create pages, invite contributors, and share memories today.
The technical choices we made — Go, vanilla JS, PostgreSQL, privacy by default — reflect a core belief: a platform built for people during difficult moments should be as simple and reliable as possible. No unnecessary dependencies. No unexpected behavior. Nothing that gets in the way.
If you are building something that handles sensitive personal content and want to talk through the architecture, get in touch.
Newsletter
Straight talk on software from people who ship it
Occasional insights from the Signal Shift Labs team on building production-ready products. Plus a free copy of our 12-Week MVP Playbook. Unsubscribe anytime.
Does this sound like how you want to build?
We take on a small number of projects each quarter. If you have an idea, urgency, and budget, we'd love to hear from you.
Start a conversation →