DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our QR codes are 5,906 characters of base64 in a Postgres text column, and the row has to exist before the image can be made

A venue using PubTrivia puts a printed card on every table, and the card is a QR code that takes a phone straight into that night's quiz as that table. It is the joining feature, and from the host's side it is one button: say how many tables you have, get a PDF of cards to print.

The URL on the card is this:

async function generateQrCode(venueId: string, tableId: string) {
  const playerUrl = `${PUBLIC_URL}/play/${venueId}/${tableId}`

  return QRCode.toDataURL(playerUrl, {
    width: 512,
    margin: 2,
    color: { dark: '#000000', light: '#FFFFFF' },
  })
}
Enter fullscreen mode Exit fullscreen mode

Two ids. The venue's, which we have, and the table's, which we do not have yet, because the table is the row we are in the middle of creating and its id comes from Postgres:

export const tablesTable = pgTable('tables', {
    id: uuid('id').primaryKey().defaultRandom(),
    venue_id: uuid('venue_id').notNull().references(() => venuesTable.id),
    table_number: text('table_number').notNull(),
    qr_code_url: text('qr_code_url').notNull(),
    is_active: boolean('is_active').notNull().default(true),
    created_at: timestamp('created_at').notNull().defaultNow(),
})
Enter fullscreen mode Exit fullscreen mode

qr_code_url is notNull, so the insert cannot leave it out, and the value it needs is a function of the id the insert is about to generate. The code resolves that the obvious way:

const newTables = await db
    .insert(tablesTable)
    .values(
      tableNames.map((tableName) => ({
        venue_id: venueId,
        table_number: tableName,
        qr_code_url: 'placeholder',
        is_active: true,
      }))
    )
    .returning()

await Promise.all(
    newTables.map(async (table) => {
      const qrCodeDataUrl = await generateQrCode(venueId, table.id)

      await db
        .update(tablesTable)
        .set({ qr_code_url: qrCodeDataUrl })
        .where(eq(tablesTable.id, table.id))
    })
)
Enter fullscreen mode Exit fullscreen mode

The string 'placeholder' is in the database for as long as it takes the second statement to land. The server action caps a single call at 100 tables, so the upper bound on one click is one multi-row insert and a hundred concurrent single-row updates.

What the column actually holds

Not a URL. A data URL, which is the entire PNG, base64 encoded, in a text column. Measured with the same four options the code passes, against a realistic 101-character player URL:

url length          101
dataurl chars      5906
size                5.8 KB
prefix              data:image/png;base64,iVBORw0K
Enter fullscreen mode Exit fullscreen mode

It is reproducible in one file if you have the qrcode package to hand:

import QRCode from 'qrcode'
const d = await QRCode.toDataURL(
  'https://pub-trivia.app/play/<uuid>/<uuid>',
  { width: 512, margin: 2, color: { dark: '#000000', light: '#FFFFFF' } }
)
console.log(d.length)
Enter fullscreen mode Exit fullscreen mode

Drop width: 512 and the same payload comes out at 3,014 characters, so more than half of what we store is the decision to rasterise at 512 pixels rather than let the renderer scale a smaller matrix. The Pro plan allows 50 active tables, which is about 288 KB of base64 for one venue's set, and the unlimited plan has no number to put here at all.

Three places that is more than it sounds

The tables page reads all of it. getVenueTables is a bare db.select() with no column list, so every load of the tables screen pulls every data URL out of Postgres. That one is defensible: the page renders the codes, and the download-all-as-PDF button takes tables: SelectTable[] and needs 50 images client side to lay out an A4 sheet.

The data export reads all of it too. The privacy export in app/dashboard/settings/privacy-actions.ts is nine selects across the account's rows, and the venue's tables go in whole:

venue
    ? db.select().from(tablesTable).where(eq(tablesTable.venue_id, venue.id))
    : Promise.resolve([]),
Enter fullscreen mode Exit fullscreen mode
tables: venueTables,
Enter fullscreen mode Exit fullscreen mode

So a venue that asks for a copy of its data gets a JSON file where the largest single thing is pictures of URLs that are already spelled out in the same file, twice over, as id and venue_id.

Deleting a table does not delete the card. Delete is a soft delete, and it is byte-for-byte the same write as switching the table off:

// deleteTable
await db.update(tablesTable).set({ is_active: false })...
// toggleTableActive(tableId, false)
await db.update(tablesTable).set({ is_active: isActive })...
Enter fullscreen mode Exit fullscreen mode

The row stays, the base64 stays, and the printed card on the physical table stays too. The player side is what stops it mattering: every lookup of a table filters on is_active, so a scan of a retired card finds nothing rather than joining something it should not. Meanwhile the plan limit counts only active tables, so switching a table off gives the quota back while leaving the storage where it was.

The same four options, with nothing stored

The irony is that we already have the cheap version in production. The free QR code generator on the marketing site encodes with the identical options:

const QRCode = (await import("qrcode")).default
await QRCode.toDataURL(target, { width: 512, margin: 2, ... })
Enter fullscreen mode Exit fullscreen mode

The difference is where that line runs. On the public tool it is a dynamic import inside a click handler in the reader's browser, there is no server call and nothing is persisted. Open the Network tab and type a URL into it: the only request is the chunk that carries qrcode itself, nothing you typed is sent anywhere, and the PDF layout pulls in jspdf the same way when you press download.

The dashboard materialises instead, and the reason is the print sheet: the PDF needs all the images at once, in a client component, and reading them from rows the page already fetched is one less round trip than asking the server to draw 50 codes on demand.

That is a real trade-off, but it is not the one the code is making right now. A QR matrix is a pure function of a string, and the string is two ids already in the row. Rendering in the client from id and venue_id would turn 5,906 characters per table into zero, make the 'placeholder' state impossible, remove a hundred updates from the create path, and shrink the data export to the ids it was always derivable from. The column stays only as long as somebody can name something it does that the two ids cannot.

If you want to see the printable end of it, the joining feature page shows the cards, and the free tool above will generate and lay out a sheet for you without an account.

Top comments (0)