DEV Community

Cover image for I failed a "design a document editor" interview, so I built one
Larbi Sahli
Larbi Sahli

Posted on

I failed a "design a document editor" interview, so I built one

How I built a drag-and-drop resume editor

Roleframe exists because of an interview I failed. After a long job hunt I finally landed a system design interview, and the question was one I had never worked on: design a document editor. I did my best, there was no offer, and the problem would not leave me alone.

So I learned it properly. How editors record every change. How layout engines decide where things go. How professional tools are built under the hood. Somewhere in the middle of that I looked back at the resume builders I had used during my own job hunt, and I finally saw what they are.

Every resume builder is a form

Strip the marketing off almost any resume builder and you find the same shape: a schema and a template renderer. Fixed fields on the left, a static preview on the right, rules about what your resume is allowed to look like. Want a section the schema does not have? Too bad. Want two entries side by side? Not your call.

That shape is not an accident. A form is the part you can build in a weekend. A real editor, where you drag sections around, control the layout, and still get a clean multi-page PDF at the end, is the hard part. Every builder I used routed around it. And after learning editors from the inside because of one failed interview, I held exactly the knowledge needed to build the part everyone skips. So that is what Roleframe became: a real drag-and-drop resume editor, with AI tailoring sitting on top of it rather than replacing it.

Here is how the editor actually works. Stack, for context: Next.js with the App Router, React, TypeScript, Tailwind, Postgres with raw SQL and no ORM, and background jobs on a task runner, and a custom event sourcing engine written from scratch.

The state React renders is a map, not a list

You cannot render a log. The event log is the source of truth, but React needs a current value, so every append materializes one. The part worth talking about is that the materialized shape is chosen for rendering, and rendering wants something quite different from storage.

Almost nothing in it is an array. Blocks are an object keyed by block id. Overlays are an object keyed by overlay id. Pages are an object keyed by page id, and inside a page its layout nodes are an object keyed by node id. The only ordered list anywhere in the model is the list of child ids hanging off a layout node, which is exactly where "what order things appear in" belongs and nowhere else.

type DocumentState = {
  blocks: { byId: Record<BlockId, Block> };
  overlays: { byId: Record<OverlayId, Overlay> };
  pages: Record<
    PageId,
    {
      rootId: NodeId;
      byId: Record<NodeId, LayoutNode>;
    }
  >;
};

type LayoutNode =
  // The one ordered list in the entire model.
  | { kind: "container"; children: NodeId[] }
  // The tree points at content by id. It never contains content.
  | { kind: "blockRef"; blockId: BlockId };
Enter fullscreen mode Exit fullscreen mode

Three things fall out of that shape.

A component subscribes to one id. A block selects its own entity by id and gets a stable reference back. Editing a different block does not change that reference, so it does not re-render, and I never had to think about it.

function Block({ blockId }: { blockId: BlockId }) {
  const block = useSelector((s) => s.doc.blocks.byId[blockId]);
  // Editing a different block leaves this reference untouched, so this
  // component does not re-render. No memo, no custom comparator, nothing.
  return <BlockView block={block} />;
}

// Order is read from the tree, never from the entity map.
function Container({ pageId, nodeId }: { pageId: PageId; nodeId: NodeId }) {
  const children = useSelector(
    (s) => s.doc.pages[pageId].byId[nodeId].children,
  );
  return children.map((id) => <Node key={id} pageId={pageId} nodeId={id} />);
}
Enter fullscreen mode Exit fullscreen mode

Put those same entities in an array and you get the opposite default: updating one entry produces a new array, every consumer sees a changed reference, and you spend the next month memoizing your way out of a problem the shape created.

One deliberate omission. The usual normalization recipe keeps a map of entities by id next to a flat array of all the ids. I dropped the array. Order already lives in the layout tree, so a second list would be a second source of truth for ordering: two places to update, and one of them drifting eventually. Where the answer already exists somewhere, do not keep a cached copy of it that can disagree.

Why event sourcing

The first big decision: documents are not saved as whole-state blobs. Every edit is an event, and the document is the result of replaying its event log.

I did not pick event sourcing because it is fashionable. I picked it because three problems I had to solve anyway fall out of it almost for free:

  • Undo. When every change is a discrete, typed event, stepping backward is a first-class operation instead of a diffing afterthought.
  • Sync. Sending small events over the wire beats re-uploading the whole document on every keystroke, and the server can reason about exactly what changed.
  • Offline resilience. If the network drops, events pile up locally and flush when it returns. Nothing is lost, because nothing was ever "the one live copy in a textarea".
  • Collaboration, when I get there. An append-only log with a strict per-document sequence is most of what multiplayer needs. It is not built yet, but the ordering guarantees already are, so it becomes a feature instead of a rewrite.
  • AI that writes into the document, not around it. In progress. Because every edit is an event, the model can emit the same typed events a person would, building a resume from a blank page one step at a time. You watch it happen, every step is undoable, and an AI edit is auditable exactly like a human one, because it goes through the same log.

Blob-save gives you none of that. You get last-write-wins, a lost update the first time two tabs are open, and undo bolted on client-side with no server truth behind it.

Typed events, and patches you are allowed to make

An event log is only as trustworthy as the events you accept. Mine come in two flavors. Layout operations (move a node, wrap a container, delete a page) are fully typed variants. Content edits are JSON Patches, and every patch event declares the subtree it is allowed to touch. Not one global allowlist: the reducer that handles the event supplies the prefixes, so the permission lives next to the code that applies it.

case "instance.data.patched": {
  const inst = doc.instances?.byId?.[event.payload.instanceId];
  if (!inst) return;
  inst.data ||= {};
  applyJsonPatchToTarget(inst, event.payload.ops, ["/data"]);
  inst.updatedAt = event.at_ms;
  return;
}

case "instance.styles.patched": {
  // ...
  applyJsonPatchToTarget(inst, event.payload.ops, [
    "/contentStyle",
    "/layoutStyle",
  ]);
  return;
}

case "layout.node.styles.patched": {
  // Intentionally narrow: a layout patch may only ever change a width.
  applyJsonPatchToTarget(node.layoutStyle, ops, ["/width"]);
  return;
}
Enter fullscreen mode Exit fullscreen mode

A styles event physically cannot reach /data. A layout event cannot reach anything but a width. "Patch anything" becomes "patch this subtree", and the blast radius of a compromised client is whatever the narrowest reducer allows.

The op set is narrow too. Only add and replace exist. remove is commented out rather than deleted, so nobody re-adds it without reading why:

export const PatchOpSchema = z.discriminatedUnion("op", [
  z.object({ op: z.literal("add"), path: JsonPointerSchema, value: BoundedValueSchema }),
  z.object({ op: z.literal("replace"), path: JsonPointerSchema, value: BoundedValueSchema }),
  // z.object({ op: z.literal("remove"), path: JsonPointerSchema }),
]);

// Cap the ops per event, and the bytes per value, or one op can bloat the log.
export const PatchOpsSchema = z.array(PatchOpSchema).min(1).max(200);

Enter fullscreen mode Exit fullscreen mode

On the client, events apply optimistically through Immer, so the UI feels instant. The same event definitions run on both sides: the client applies them for responsiveness, the server applies them for truth.

And here is the hole that taught me an allowlist is not enough. Deletion was disabled, and deletion happened anyway. fast-json-patch runs with validateOperation: false, so a schema-valid { op: "replace", path: "/data/title" } with no value executed obj.title = undefined, and JSON.stringify then dropped the key entirely. A replace had become a covert delete of any allowlisted field, which is exactly the thing commenting out remove was supposed to prevent.

The fix is not to reject valueless ops. They are routine traffic: panels build ops straight from form state, value: values?.location, and when the field is empty the key vanishes in serialization. Rejecting would 400 a live field-clearing gesture and the outbox would retry that same request forever. So the value coerces to null instead. The user's intent is preserved, the stored event says plainly what it did, and deletion stops being a reachable effect, because the key is now set rather than removed.

The general lesson: an allowlist tells you where a write may land, and says nothing about what the write does when it gets there. Both need a bound.

The outbox, and why duplicate sends are normal

Optimistic local application creates the obvious question: what happens between "applied on my screen" and "safe on the server"? The answer is an outbox. Every event the client produces is queued locally, and a sync layer flushes the queue to an append endpoint, debounced while you type, immediately when the tab blurs or is about to go hidden.

The append endpoint is where correctness lives. The client is optimistic; the server is paranoid. Each append request carries a request id, and the server processes it roughly like this:

// POST /docs/:docId/append  { requestId, events }
async function append(docId: string, requestId: string, events: DocEvent[]) {
  return tx(async (db) => {
    const seen = await db.findAppendRequest(docId, requestId);
    if (seen) return { ackedSeq: seen.ackedSeq }; // replay: same answer, no double apply

    const doc = await db.lockDocument(docId); // one writer at a time per doc
    validateEvents(events);

    let seq = doc.lastSeq;
    for (const event of events) await db.insertEvent(docId, ++seq, event);

    await db.saveCurrentState(docId, replay(doc.state, events), seq);
    await db.recordAppendRequest(docId, requestId, seq);
    return { ackedSeq: seq };
  });
}
Enter fullscreen mode Exit fullscreen mode

Three things carry the weight here:

  1. Idempotency via request ids. A retried request returns the original ack instead of applying twice.
  2. A monotonically increasing sequence number per document. Every event gets exactly one slot, and the returned ackedSeq tells the client precisely which outbox entries it can prune.
  3. Materialized current state, plus periodic snapshots. Reads never replay the full log. Replay from a snapshot is the recovery path, not the hot path.

The design consequence I want to underline: duplicate client sends are normal and harmless. Networks time out after the server already committed. Tabs get frozen mid-flush and retry on wake. Users close laptops at the worst possible moment. If your protocol treats a duplicate as an error, you will chase phantom bugs forever. If the server is idempotent, a duplicate becomes a boring, expected event. A whole class of my early "sync bugs" stopped existing once I made the server the correctness layer and let clients be messy.

Page breaks are a planning problem

Multi-page is where WYSIWYG editors go to die. Text reflows, a section grows by one line, and suddenly a heading is stranded alone at the bottom of page one.

Roleframe runs pagination through a page-break planner. It measures the rendered blocks, walks the document, and decides which blocks move to the next page. Decisions happen at block granularity, so the planner reasons about "this entry moves" rather than slicing through the middle of a line.

And one honest limitation, on purpose: two-column layouts are not auto-paginated. In a single column, "what moves to the next page" has one defensible answer. With two columns it does not. If the left column overflows, does the overflow continue in the left column of page two? Does the right column rebalance to match? Either guess is wrong for someone, and a resume is exactly the document where a surprising guess costs you. So for two-column layouts the planner steps back and page breaks are the user's decision. Shipping a clear manual control beat shipping a clever guess I could not defend.

WYSIWYG to PDF, for real

"What you see is what you get" dies at the moment of export in most tools. The preview is one rendering engine and the PDF is another, so margins shift, fonts substitute, and line breaks move.

My rule was: the PDF must be the same pixels as the editor. So export renders the exact same HTML and CSS as the editor, through a separate export service running a warm pool of headless Chromium instances on preemptible VMs. Same markup, same stylesheets, same rendering engine, pinned server-side. What you see is what prints. And the output is real selectable text, not a screenshot of the page.

Why not just print CSS and window.print()? I tried to want that answer, because it is so much less infrastructure. It was not enough:

  • Fragmentation is where browser print support gets patchy, and my page breaks have to match the planner's decisions exactly, not whatever the fragmentation algorithm feels like doing that day.
  • The user's browser is out of my control: version, installed fonts, print dialog defaults, headers and footers. Every variable I cannot pin is a future support email.
  • Export has to work with no print dialog at all, server-side, for anonymous users on any device.

A warm pool, because launching Chromium cold for each export is exactly the kind of latency users notice. Preemptible VMs, because a solo founder pays his own cloud bill, and the pool is disposable by design: any instance can vanish and the service keeps rendering.

Anonymous-first changes your abuse math

The editor at roleframe.ai/resume-builder is free with no account. No card, no watermark, no trial, and no export quota counting down. Drafts are held against a cookie session.

I built it that way because I remember needing it. But anonymous-first quietly deletes your favorite abuse control: the user id. You cannot rate-limit "the user" when there is no user, and cookies are worthless as a limit key because an abuser simply does not send one.

What I ended up with is layered:

function limitKey(ip: string, salt: string) {
  const subject = isIPv6(ip) ? collapseTo64(ip) : ip; // one machine ~ one /64
  return hmac(salt, subject); // never key on, or store, the raw IP
}

// Layer 1: per-key limits on each expensive route
// Layer 2: broader per-key limits across the whole anonymous surface
// Layer 3: constant-key global fuses: one shared counter per costly
//          resource, capping total spend no matter how many IPs show up
Enter fullscreen mode Exit fullscreen mode

Keys are salted hashes of the IP, never the IP itself, which matters to me beyond engineering. Roleframe is built in Poland, under GDPR, and does not sell personal data or run third-party tracking cookies. "Do not hold raw IPs where a hash will do" is the same instinct applied lower in the stack. IPv6 gets collapsed to a /64 first, because a single machine typically has an entire /64 to rotate through, and per-address limits would be a joke.

The layer people skip is the last one. Per-key limits stop one greedy client. They do nothing against many keys, a botnet or a rotating proxy pool where each address politely stays under its own limit. The constant-key fuse is the answer: a single global counter on each expensive path, shared by everyone. If total consumption spikes past what any honest population would produce, the fuse trips and the expensive path degrades instead of the bill exploding. The honest trade-off: a fuse is a blunt instrument, and where you set the threshold is a judgement call you only get to make properly once you have watched your own traffic for a while. I take it, because the free tier only stays free if it cannot be farmed.

And the lesson that has nothing to do with architecture: building alone means some things ship slower than I would like. Every feature, every fix, every support reply is me. The trade is that nobody here is chasing engagement metrics, and nobody is planning to sell your data.

The editor is free because I remember what it felt like to need it. No account, no card, no watermark: roleframe.ai/resume-builder. The full interview story is at roleframe.ai/about.

If Roleframe helps you land something, tell me. That is the part of this work no metric can touch.

Top comments (0)